mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
feat: add vici dial controls from UI (#563)
* ViciDial Working * feat(telephony): add FreeSWITCH provider to upstream-PBX seam Generalize the upstream-PBX capture and control paths to dispatch by provider. FreeSWITCH bridges in with X-PBX-* headers and is driven over the Event Socket Library (uuid_kill / uuid_transfer) by the channel UUID, alongside the existing VICIdial ra_call_control path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * add vici specific configs * feat(telephony): env-based VICIdial config + address3 post-call routing Move VICIdial agent/non-agent API and FreeSWITCH ESL connection settings out of hardcoded POC values into environment variables so the same image works against a PBX on another server. Add VICIdial update_lead forwarding: X-VICI-UPDATE-LEAD_* extracted variables are mapped to lead columns and pushed via the non-agent API before a transfer, with reserved API-control params dropped. Add hardcoded address3 disposition routing (Y/N -> in-group transfer, else hang up) and a synchronous final variable extraction so the transfer path sees the freshest conversation state. Skip upstream_pbx capture on non-PJSIP channels to avoid Asterisk 500s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: run formatter * feat: make vici configurable from UI * chore: clean up PR * fix: incorporate review comments * chore: incorporate review comments * chore: generate client * chore: incporporate review comments * chore: incorporate review comments --------- Co-authored-by: Dograh POC <payment@dograh.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f7b47a1b4
commit
d8cd34e8d2
51 changed files with 2886 additions and 102 deletions
|
|
@ -56,6 +56,7 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useOrgConfig } from "@/context/OrgConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { resolveWebhookBaseUrl } from "@/lib/webhookUrl";
|
||||
|
|
@ -69,6 +70,7 @@ export default function TelephonyConfigurationDetailPage() {
|
|||
|
||||
const { user, getAccessToken, loading: authLoading } = useAuth();
|
||||
const { config: appConfig } = useAppConfig();
|
||||
const { externalPbxIntegrationsEnabled } = useOrgConfig();
|
||||
const inboundWebhookUrl = `${resolveWebhookBaseUrl(appConfig?.tunnelUrl)}${INBOUND_WEBHOOK_PATH}`;
|
||||
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
|
||||
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
|
||||
|
|
@ -248,11 +250,13 @@ export default function TelephonyConfigurationDetailPage() {
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
{Object.entries(config.credentials ?? {}).map(([k, v]) => (
|
||||
{Object.entries(config.credentials ?? {})
|
||||
.filter(([key]) => key !== "external_pbx" || externalPbxIntegrationsEnabled)
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">{k}</dt>
|
||||
<dd className="font-mono text-right truncate max-w-[60%]">
|
||||
{String(v ?? "")}
|
||||
{v && typeof v === "object" ? "Configured" : String(v ?? "")}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ContextDestinationRouteRow } from "../../config";
|
||||
import { TransferCallToolConfig } from "./TransferCallToolConfig";
|
||||
|
||||
const noop = vi.fn();
|
||||
|
||||
function ContextMappingHarness() {
|
||||
const [routes, setRoutes] = useState<ContextDestinationRouteRow[]>([
|
||||
{
|
||||
id: "existing-route",
|
||||
context_value: "support",
|
||||
destination: "PJSIP/support",
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<TransferCallToolConfig
|
||||
name="Transfer call"
|
||||
onNameChange={noop}
|
||||
description=""
|
||||
onDescriptionChange={noop}
|
||||
destinationSource="context_mapping"
|
||||
onDestinationSourceChange={noop}
|
||||
destination=""
|
||||
onDestinationChange={noop}
|
||||
messageType="none"
|
||||
onMessageTypeChange={noop}
|
||||
customMessage=""
|
||||
onCustomMessageChange={noop}
|
||||
audioRecordingId=""
|
||||
onAudioRecordingIdChange={noop}
|
||||
timeout={30}
|
||||
onTimeoutChange={noop}
|
||||
resolverUrl=""
|
||||
onResolverUrlChange={noop}
|
||||
resolverCredentialUuid=""
|
||||
onResolverCredentialUuidChange={noop}
|
||||
resolverHeaders={[]}
|
||||
onResolverHeadersChange={noop}
|
||||
resolverTimeoutMs={3000}
|
||||
onResolverTimeoutMsChange={noop}
|
||||
resolverWaitMessage=""
|
||||
onResolverWaitMessageChange={noop}
|
||||
parameters={[]}
|
||||
onParametersChange={noop}
|
||||
presetParameters={[]}
|
||||
onPresetParametersChange={noop}
|
||||
externalPbxRoutingEnabled
|
||||
contextMappingPath="department"
|
||||
onContextMappingPathChange={noop}
|
||||
contextDestinationRoutes={routes}
|
||||
onContextDestinationRoutesChange={setRoutes}
|
||||
fallbackDestination=""
|
||||
onFallbackDestinationChange={noop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TransferCallToolConfig context mappings", () => {
|
||||
it("preserves the focused row when an earlier mapping is removed", () => {
|
||||
render(<ContextMappingHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add mapping" }));
|
||||
|
||||
const addedDestination = screen.getByLabelText("PBX destination 2");
|
||||
addedDestination.focus();
|
||||
expect(document.activeElement).toBe(addedDestination);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove mapping 1" }));
|
||||
|
||||
expect(screen.getByLabelText("PBX destination 1")).toBe(addedDestination);
|
||||
expect(document.activeElement).toBe(addedDestination);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
||||
import {
|
||||
|
|
@ -12,6 +14,7 @@ import {
|
|||
type ToolParameter,
|
||||
UrlInput,
|
||||
} from "@/components/http";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -20,6 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import {
|
||||
type ContextDestinationRouteRow,
|
||||
type EndCallMessageType,
|
||||
type TransferDestinationSource,
|
||||
} from "../../config";
|
||||
|
|
@ -56,6 +60,13 @@ export interface TransferCallToolConfigProps {
|
|||
onParametersChange: (parameters: ToolParameter[]) => void;
|
||||
presetParameters: PresetToolParameter[];
|
||||
onPresetParametersChange: (parameters: PresetToolParameter[]) => void;
|
||||
externalPbxRoutingEnabled: boolean;
|
||||
contextMappingPath: string;
|
||||
onContextMappingPathChange: (path: string) => void;
|
||||
contextDestinationRoutes: ContextDestinationRouteRow[];
|
||||
onContextDestinationRoutesChange: (routes: ContextDestinationRouteRow[]) => void;
|
||||
fallbackDestination: string;
|
||||
onFallbackDestinationChange: (destination: string) => void;
|
||||
}
|
||||
|
||||
export function TransferCallToolConfig({
|
||||
|
|
@ -90,6 +101,13 @@ export function TransferCallToolConfig({
|
|||
onParametersChange,
|
||||
presetParameters,
|
||||
onPresetParametersChange,
|
||||
externalPbxRoutingEnabled,
|
||||
contextMappingPath,
|
||||
onContextMappingPathChange,
|
||||
contextDestinationRoutes,
|
||||
onContextDestinationRoutesChange,
|
||||
fallbackDestination,
|
||||
onFallbackDestinationChange,
|
||||
}: TransferCallToolConfigProps) {
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -217,14 +235,22 @@ export function TransferCallToolConfig({
|
|||
Choose whether the transfer uses a configured destination or resolves one from an HTTP endpoint.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs
|
||||
{!externalPbxRoutingEnabled && destinationSource === "context_mapping" ? (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
This tool has advanced external-PBX routing configured. Enable
|
||||
External PBX integrations in Platform Settings to view or change it.
|
||||
</div>
|
||||
) : <Tabs
|
||||
value={destinationSource}
|
||||
onValueChange={(v) => onDestinationSourceChange(v as TransferDestinationSource)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className={`grid w-full ${externalPbxRoutingEnabled ? "grid-cols-3" : "grid-cols-2"}`}>
|
||||
<TabsTrigger value="static">Static / Template</TabsTrigger>
|
||||
<TabsTrigger value="dynamic">Dynamic HTTP Resolver</TabsTrigger>
|
||||
{externalPbxRoutingEnabled && (
|
||||
<TabsTrigger value="context_mapping">Context Mapping</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="static" className="space-y-4 mt-4">
|
||||
|
|
@ -343,7 +369,100 @@ export function TransferCallToolConfig({
|
|||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{externalPbxRoutingEnabled && (
|
||||
<TabsContent value="context_mapping" className="space-y-5 mt-4">
|
||||
<div>
|
||||
<Label>External PBX Context Routing</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Resolve a gathered-context value to a provider-native destination.
|
||||
Matching ignores case and surrounding whitespace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pbx-context-path">Gathered Context Field</Label>
|
||||
<Input
|
||||
id="pbx-context-path"
|
||||
value={contextMappingPath}
|
||||
onChange={(event) => onContextMappingPathChange(event.target.value)}
|
||||
placeholder="qualified or extracted_variables.qualified"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Value to Destination Mappings</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onContextDestinationRoutesChange([
|
||||
...contextDestinationRoutes,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
context_value: "",
|
||||
destination: "",
|
||||
},
|
||||
])}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Add mapping
|
||||
</Button>
|
||||
</div>
|
||||
{contextDestinationRoutes.map((route, index) => (
|
||||
<div key={route.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<Input
|
||||
aria-label={`Context value ${index + 1}`}
|
||||
value={route.context_value}
|
||||
onChange={(event) => onContextDestinationRoutesChange(
|
||||
contextDestinationRoutes.map((item) =>
|
||||
item.id === route.id
|
||||
? { ...item, context_value: event.target.value }
|
||||
: item
|
||||
)
|
||||
)}
|
||||
placeholder="Context value"
|
||||
/>
|
||||
<Input
|
||||
aria-label={`PBX destination ${index + 1}`}
|
||||
value={route.destination}
|
||||
onChange={(event) => onContextDestinationRoutesChange(
|
||||
contextDestinationRoutes.map((item) =>
|
||||
item.id === route.id
|
||||
? { ...item, destination: event.target.value }
|
||||
: item
|
||||
)
|
||||
)}
|
||||
placeholder="Provider destination"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove mapping ${index + 1}`}
|
||||
onClick={() => onContextDestinationRoutesChange(
|
||||
contextDestinationRoutes.filter((item) => item.id !== route.id)
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{contextDestinationRoutes.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add at least one mapping.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pbx-fallback-destination">Fallback Destination (Optional)</Label>
|
||||
<Input
|
||||
id="pbx-fallback-destination"
|
||||
value={fallbackDestination}
|
||||
onChange={(event) => onFallbackDestinationChange(event.target.value)}
|
||||
placeholder="Provider-native fallback destination"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -40,10 +40,12 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TOOL_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { useOrgConfig } from "@/context/OrgConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import {
|
||||
type ContextDestinationRouteRow,
|
||||
createMcpDefinition,
|
||||
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
||||
type EndCallMessageType,
|
||||
|
|
@ -84,6 +86,7 @@ function headersToRows(headers: Record<string, string> | undefined | null): KeyV
|
|||
export default function ToolDetailPage() {
|
||||
const { toolUuid } = useParams<{ toolUuid: string }>();
|
||||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const { externalPbxIntegrationsEnabled } = useOrgConfig();
|
||||
const router = useRouter();
|
||||
|
||||
const [tool, setTool] = useState<ToolResponse | null>(null);
|
||||
|
|
@ -138,6 +141,10 @@ export default function ToolDetailPage() {
|
|||
const [transferResolverWaitMessage, setTransferResolverWaitMessage] = useState("");
|
||||
const [transferParameters, setTransferParameters] = useState<ToolParameter[]>([]);
|
||||
const [transferPresetParameters, setTransferPresetParameters] = useState<PresetToolParameter[]>([]);
|
||||
const [transferContextMappingPath, setTransferContextMappingPath] = useState("");
|
||||
const [transferContextDestinationRoutes, setTransferContextDestinationRoutes] =
|
||||
useState<ContextDestinationRouteRow[]>([]);
|
||||
const [transferFallbackDestination, setTransferFallbackDestination] = useState("");
|
||||
|
||||
// HTTP API form state - custom message type
|
||||
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
|
||||
|
|
@ -237,6 +244,16 @@ export default function ToolDetailPage() {
|
|||
required: p.required ?? true,
|
||||
})),
|
||||
);
|
||||
setTransferContextMappingPath(config.context_mapping?.context_path || "");
|
||||
setTransferContextDestinationRoutes(
|
||||
(config.context_mapping?.routes || []).map((route) => ({
|
||||
...route,
|
||||
id: crypto.randomUUID(),
|
||||
}))
|
||||
);
|
||||
setTransferFallbackDestination(
|
||||
config.context_mapping?.fallback_destination || ""
|
||||
);
|
||||
} else {
|
||||
setTransferDestinationSource("static");
|
||||
setTransferDestination("");
|
||||
|
|
@ -251,6 +268,9 @@ export default function ToolDetailPage() {
|
|||
setTransferResolverWaitMessage("");
|
||||
setTransferParameters([]);
|
||||
setTransferPresetParameters([]);
|
||||
setTransferContextMappingPath("");
|
||||
setTransferContextDestinationRoutes([]);
|
||||
setTransferFallbackDestination("");
|
||||
}
|
||||
} else if (tool.category === "mcp") {
|
||||
// Populate MCP specific fields
|
||||
|
|
@ -413,6 +433,28 @@ export default function ToolDetailPage() {
|
|||
return;
|
||||
}
|
||||
}
|
||||
if (transferDestinationSource === "context_mapping") {
|
||||
if (!transferContextMappingPath.trim()) {
|
||||
setError("Please enter a gathered-context field for PBX routing");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
transferContextDestinationRoutes.length === 0 ||
|
||||
transferContextDestinationRoutes.some(
|
||||
(route) => !route.context_value.trim() || !route.destination.trim()
|
||||
)
|
||||
) {
|
||||
setError("Add at least one complete context value to destination mapping");
|
||||
return;
|
||||
}
|
||||
const routeValues = transferContextDestinationRoutes.map((route) =>
|
||||
route.context_value.trim().toLocaleLowerCase()
|
||||
);
|
||||
if (new Set(routeValues).size !== routeValues.length) {
|
||||
setError("Destination mapping context values must be unique");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (tool.category === "mcp") {
|
||||
// Validate MCP server URL (must be http(s))
|
||||
if (!mcpUrl.trim()) {
|
||||
|
|
@ -536,6 +578,17 @@ export default function ToolDetailPage() {
|
|||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
context_mapping: transferDestinationSource === "context_mapping"
|
||||
? {
|
||||
context_path: transferContextMappingPath.trim(),
|
||||
routes: transferContextDestinationRoutes.map((route) => ({
|
||||
context_value: route.context_value.trim(),
|
||||
destination: route.destination.trim(),
|
||||
})),
|
||||
fallback_destination:
|
||||
transferFallbackDestination.trim() || undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
// Build transfer call request body
|
||||
requestBody = {
|
||||
|
|
@ -871,6 +924,13 @@ const data = await response.json();`;
|
|||
onParametersChange={setTransferParameters}
|
||||
presetParameters={transferPresetParameters}
|
||||
onPresetParametersChange={setTransferPresetParameters}
|
||||
externalPbxRoutingEnabled={externalPbxIntegrationsEnabled}
|
||||
contextMappingPath={transferContextMappingPath}
|
||||
onContextMappingPathChange={setTransferContextMappingPath}
|
||||
contextDestinationRoutes={transferContextDestinationRoutes}
|
||||
onContextDestinationRoutesChange={setTransferContextDestinationRoutes}
|
||||
fallbackDestination={transferFallbackDestination}
|
||||
onFallbackDestinationChange={setTransferFallbackDestination}
|
||||
/>
|
||||
) : isMcpTool ? (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,22 @@ import type {
|
|||
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
|
||||
|
||||
export type EndCallMessageType = "none" | "custom" | "audio";
|
||||
export type TransferDestinationSource = "static" | "dynamic";
|
||||
export type TransferDestinationSource = "static" | "dynamic" | "context_mapping";
|
||||
|
||||
export interface ContextDestinationRoute {
|
||||
context_value: string;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
export interface ContextDestinationRouteRow extends ContextDestinationRoute {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ContextDestinationMappingConfig {
|
||||
context_path: string;
|
||||
routes: ContextDestinationRoute[];
|
||||
fallback_destination?: string | null;
|
||||
}
|
||||
|
||||
export interface TransferResolverConfig {
|
||||
type: "http";
|
||||
|
|
@ -34,6 +49,7 @@ export interface TransferResolverConfig {
|
|||
export interface ExtendedTransferCallConfig extends TransferCallConfig {
|
||||
destination_source?: TransferDestinationSource;
|
||||
resolver?: TransferResolverConfig | null;
|
||||
context_mapping?: ContextDestinationMappingConfig | null;
|
||||
}
|
||||
|
||||
export interface ToolCategoryConfig {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -6,10 +7,12 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useOrgConfig } from "@/context/OrgConfigContext";
|
||||
import {
|
||||
AmbientNoiseConfiguration,
|
||||
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
|
||||
DEFAULT_TURN_START_MIN_WORDS,
|
||||
ExternalPBXFieldMapping,
|
||||
resolveWorkflowConfigurations,
|
||||
TURN_START_STRATEGY_OPTIONS,
|
||||
TurnStartStrategy,
|
||||
|
|
@ -32,6 +35,7 @@ export const ConfigurationsDialog = ({
|
|||
workflowName,
|
||||
onSave
|
||||
}: ConfigurationsDialogProps) => {
|
||||
const { externalPbxIntegrationsEnabled } = useOrgConfig();
|
||||
const resolvedWorkflowConfigurations = resolveWorkflowConfigurations(workflowConfigurations);
|
||||
const [name, setName] = useState<string>(workflowName);
|
||||
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
|
||||
|
|
@ -61,10 +65,18 @@ export const ConfigurationsDialog = ({
|
|||
const [contextCompactionEnabled, setContextCompactionEnabled] = useState<boolean>(
|
||||
resolvedWorkflowConfigurations.context_compaction_enabled
|
||||
);
|
||||
const [externalPbxFieldMappings, setExternalPbxFieldMappings] = useState<ExternalPBXFieldMapping[]>(
|
||||
resolvedWorkflowConfigurations.external_pbx_field_mappings
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const selectedTurnStartStrategy = TURN_START_STRATEGY_OPTIONS.find(
|
||||
(option) => option.value === turnStartStrategy
|
||||
);
|
||||
const externalPbxFieldMappingsValid = externalPbxFieldMappings.every(
|
||||
(mapping) =>
|
||||
Boolean(mapping.context_path.trim()) &&
|
||||
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(mapping.destination_field.trim())
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
|
@ -80,6 +92,7 @@ export const ConfigurationsDialog = ({
|
|||
turn_stop_strategy: turnStopStrategy,
|
||||
transcript_configuration: resolvedWorkflowConfigurations.transcript_configuration,
|
||||
context_compaction_enabled: contextCompactionEnabled,
|
||||
external_pbx_field_mappings: externalPbxFieldMappings,
|
||||
}, name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
|
|
@ -103,12 +116,13 @@ export const ConfigurationsDialog = ({
|
|||
setProvisionalVadPauseSecs(nextWorkflowConfigurations.provisional_vad_pause_secs);
|
||||
setTurnStopStrategy(nextWorkflowConfigurations.turn_stop_strategy);
|
||||
setContextCompactionEnabled(nextWorkflowConfigurations.context_compaction_enabled);
|
||||
setExternalPbxFieldMappings(nextWorkflowConfigurations.external_pbx_field_mappings);
|
||||
}
|
||||
}, [open, workflowName, workflowConfigurations]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurations</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
@ -401,13 +415,92 @@ export const ConfigurationsDialog = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{externalPbxIntegrationsEnabled && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">External PBX Field Updates</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optionally copy final gathered-context values into provider-native fields before transfer or hangup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Field Mappings</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExternalPbxFieldMappings((current) => [
|
||||
...current,
|
||||
{ context_path: "", destination_field: "" },
|
||||
])}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Add mapping
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{externalPbxFieldMappings.map((mapping, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<Input
|
||||
aria-label={`Gathered context field ${index + 1}`}
|
||||
value={mapping.context_path}
|
||||
onChange={(event) => setExternalPbxFieldMappings((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, context_path: event.target.value }
|
||||
: item
|
||||
)
|
||||
)}
|
||||
placeholder="qualified"
|
||||
/>
|
||||
<Input
|
||||
aria-label={`External PBX destination field ${index + 1}`}
|
||||
value={mapping.destination_field}
|
||||
onChange={(event) => setExternalPbxFieldMappings((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, destination_field: event.target.value }
|
||||
: item
|
||||
)
|
||||
)}
|
||||
placeholder="address3"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove external PBX field mapping ${index + 1}`}
|
||||
onClick={() => setExternalPbxFieldMappings((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index)
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{externalPbxFieldMappings.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No external fields will be updated. Context names may be direct extracted-variable names or paths such as extracted_variables.qualified.
|
||||
</p>
|
||||
)}
|
||||
{!externalPbxFieldMappingsValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Each mapping needs a context field and a destination field containing only letters, numbers, and underscores.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || (externalPbxIntegrationsEnabled && !externalPbxFieldMappingsValid)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { ArrowLeft, BookA, Brain, CalendarIcon, Clipboard, Download, ExternalLink, FileDown, Fingerprint, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
|
||||
import { ArrowLeft, BookA, Brain, CalendarIcon, Clipboard, Download, ExternalLink, FileDown, Fingerprint, Loader2, Mic, Pause, PhoneOff, Play, Plus, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -38,6 +38,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { useOrgConfig } from "@/context/OrgConfigContext";
|
||||
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
|
||||
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
|
|
@ -49,6 +50,7 @@ import {
|
|||
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS,
|
||||
DEFAULT_TURN_START_MIN_WORDS,
|
||||
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
|
||||
type ExternalPBXFieldMapping,
|
||||
resolveWorkflowConfigurations,
|
||||
TURN_START_STRATEGY_OPTIONS,
|
||||
type TurnStartStrategy,
|
||||
|
|
@ -273,6 +275,7 @@ function GeneralSection({
|
|||
workflowId: number;
|
||||
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
|
||||
}) {
|
||||
const { externalPbxIntegrationsEnabled } = useOrgConfig();
|
||||
const [name, setName] = useState(workflowName);
|
||||
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
|
||||
workflowConfigurations.ambient_noise_configuration,
|
||||
|
|
@ -298,6 +301,9 @@ function GeneralSection({
|
|||
const [includeTranscriptEndTimestamps, setIncludeTranscriptEndTimestamps] = useState(
|
||||
workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false,
|
||||
);
|
||||
const [externalPbxFieldMappings, setExternalPbxFieldMappings] = useState<ExternalPBXFieldMapping[]>(
|
||||
workflowConfigurations.external_pbx_field_mappings,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||
const [audioUploadError, setAudioUploadError] = useState<string | null>(null);
|
||||
|
|
@ -306,6 +312,11 @@ function GeneralSection({
|
|||
const selectedTurnStartStrategy = TURN_START_STRATEGY_OPTIONS.find(
|
||||
(option) => option.value === turnStartStrategy,
|
||||
);
|
||||
const externalPbxFieldMappingsValid = externalPbxFieldMappings.every(
|
||||
(mapping) =>
|
||||
Boolean(mapping.context_path.trim()) &&
|
||||
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(mapping.destination_field.trim()),
|
||||
);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
const initAmbient = workflowConfigurations.ambient_noise_configuration;
|
||||
|
|
@ -321,9 +332,11 @@ function GeneralSection({
|
|||
turnStopStrategy !== workflowConfigurations.turn_stop_strategy ||
|
||||
contextCompactionEnabled !== workflowConfigurations.context_compaction_enabled ||
|
||||
includeTranscriptEndTimestamps !==
|
||||
(workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false)
|
||||
(workflowConfigurations.transcript_configuration?.include_end_timestamps ?? false) ||
|
||||
JSON.stringify(externalPbxFieldMappings) !==
|
||||
JSON.stringify(workflowConfigurations.external_pbx_field_mappings)
|
||||
);
|
||||
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, includeTranscriptEndTimestamps, workflowConfigurations]);
|
||||
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStartStrategy, turnStartMinWords, provisionalVadPauseSecs, turnStopStrategy, contextCompactionEnabled, includeTranscriptEndTimestamps, externalPbxFieldMappings, workflowConfigurations]);
|
||||
|
||||
useUnsavedChanges("general", isDirty);
|
||||
|
||||
|
|
@ -404,6 +417,7 @@ function GeneralSection({
|
|||
...(workflowConfigurations.transcript_configuration ?? {}),
|
||||
include_end_timestamps: includeTranscriptEndTimestamps,
|
||||
},
|
||||
external_pbx_field_mappings: externalPbxFieldMappings,
|
||||
},
|
||||
name,
|
||||
);
|
||||
|
|
@ -793,10 +807,94 @@ function GeneralSection({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{externalPbxIntegrationsEnabled && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* External PBX Field Updates */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">External PBX Field Updates</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Optionally copy final gathered-context values into provider-native fields before transfer or hangup.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Field Mappings</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExternalPbxFieldMappings((current) => [
|
||||
...current,
|
||||
{ context_path: "", destination_field: "" },
|
||||
])}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Add mapping
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{externalPbxFieldMappings.map((mapping, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<Input
|
||||
aria-label={`Gathered context field ${index + 1}`}
|
||||
value={mapping.context_path}
|
||||
onChange={(event) => setExternalPbxFieldMappings((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, context_path: event.target.value }
|
||||
: item,
|
||||
)
|
||||
)}
|
||||
placeholder="qualified"
|
||||
/>
|
||||
<Input
|
||||
aria-label={`External PBX destination field ${index + 1}`}
|
||||
value={mapping.destination_field}
|
||||
onChange={(event) => setExternalPbxFieldMappings((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, destination_field: event.target.value }
|
||||
: item,
|
||||
)
|
||||
)}
|
||||
placeholder="address3"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove external PBX field mapping ${index + 1}`}
|
||||
onClick={() => setExternalPbxFieldMappings((current) =>
|
||||
current.filter((_, itemIndex) => itemIndex !== index)
|
||||
)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{externalPbxFieldMappings.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No external fields will be updated. Context names may be direct extracted-variable names or paths such as extracted_variables.qualified.
|
||||
</p>
|
||||
)}
|
||||
{!externalPbxFieldMappingsValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Each mapping needs a context field and a destination field containing only letters, numbers, and underscores.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end gap-3 border-t pt-6">
|
||||
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isDirty || (externalPbxIntegrationsEnabled && !externalPbxFieldMappingsValid)}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save General Settings"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -96,6 +96,10 @@ export type AriConfigurationRequest = {
|
|||
* websocket_client.conf connection name for externalMedia (e.g., dograh_staging)
|
||||
*/
|
||||
ws_client_name?: string;
|
||||
/**
|
||||
* Optional external PBX connected through this Asterisk instance
|
||||
*/
|
||||
external_pbx?: VicidialExternalPbxConfiguration | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*
|
||||
|
|
@ -130,6 +134,7 @@ export type AriConfigurationResponse = {
|
|||
* Ws Client Name
|
||||
*/
|
||||
ws_client_name?: string;
|
||||
external_pbx?: VicidialExternalPbxConfiguration | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*/
|
||||
|
|
@ -1329,6 +1334,46 @@ export type CloudonixConfigurationResponse = {
|
|||
from_numbers: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ContextDestinationMappingConfig
|
||||
*
|
||||
* Resolve an external-PBX destination from gathered context.
|
||||
*/
|
||||
export type ContextDestinationMappingConfig = {
|
||||
/**
|
||||
* Context Path
|
||||
*
|
||||
* Gathered-context path or extracted-variable name used for routing.
|
||||
*/
|
||||
context_path: string;
|
||||
/**
|
||||
* Routes
|
||||
*/
|
||||
routes: Array<ContextDestinationRoute>;
|
||||
/**
|
||||
* Fallback Destination
|
||||
*
|
||||
* Optional provider-native fallback destination.
|
||||
*/
|
||||
fallback_destination?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* ContextDestinationRoute
|
||||
*
|
||||
* Map one gathered-context value to an external-PBX destination.
|
||||
*/
|
||||
export type ContextDestinationRoute = {
|
||||
/**
|
||||
* Context Value
|
||||
*/
|
||||
context_value: string;
|
||||
/**
|
||||
* Destination
|
||||
*/
|
||||
destination: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreateAPIKeyRequest
|
||||
*/
|
||||
|
|
@ -2472,6 +2517,22 @@ export type EndCallToolDefinition = {
|
|||
config: EndCallConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* ExternalPBXFieldMapping
|
||||
*
|
||||
* Map one gathered-context value to a provider-native field.
|
||||
*/
|
||||
export type ExternalPbxFieldMapping = {
|
||||
/**
|
||||
* Context Path
|
||||
*/
|
||||
context_path: string;
|
||||
/**
|
||||
* Destination Field
|
||||
*/
|
||||
destination_field: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* FileDescriptor
|
||||
*
|
||||
|
|
@ -3993,6 +4054,12 @@ export type OpenAiRealtimeLlmConfiguration = {
|
|||
* Voice the model speaks in.
|
||||
*/
|
||||
voice?: string;
|
||||
/**
|
||||
* Language
|
||||
*
|
||||
* ISO 639-1 language code for input audio transcription (e.g. 'pt', 'es'). Improves transcription accuracy and latency. Leave unset to auto-detect.
|
||||
*/
|
||||
language?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -4192,6 +4259,10 @@ export type OrganizationPreferences = {
|
|||
* Timezone
|
||||
*/
|
||||
timezone?: string | null;
|
||||
/**
|
||||
* External Pbx Integrations Enabled
|
||||
*/
|
||||
external_pbx_integrations_enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -5703,6 +5774,20 @@ export type TelephonyProviderMetadata = {
|
|||
docs_url?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* TelephonyProviderUICondition
|
||||
*/
|
||||
export type TelephonyProviderUiCondition = {
|
||||
/**
|
||||
* Field
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* Equals
|
||||
*/
|
||||
equals: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* TelephonyProviderUIField
|
||||
*
|
||||
|
|
@ -5737,6 +5822,29 @@ export type TelephonyProviderUiField = {
|
|||
* Placeholder
|
||||
*/
|
||||
placeholder?: string | null;
|
||||
/**
|
||||
* Options
|
||||
*/
|
||||
options?: Array<TelephonyProviderUiOption> | null;
|
||||
visible_when?: TelephonyProviderUiCondition | null;
|
||||
/**
|
||||
* Section
|
||||
*/
|
||||
section?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* TelephonyProviderUIOption
|
||||
*/
|
||||
export type TelephonyProviderUiOption = {
|
||||
/**
|
||||
* Value
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Label
|
||||
*/
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -6029,9 +6137,9 @@ export type TransferCallConfig = {
|
|||
/**
|
||||
* Destination Source
|
||||
*
|
||||
* Whether transfer destination is static/template or resolved by HTTP.
|
||||
* Whether the destination is static/template, resolved by HTTP, or mapped from gathered context to an external-PBX destination.
|
||||
*/
|
||||
destination_source?: 'static' | 'dynamic';
|
||||
destination_source?: 'static' | 'dynamic' | 'context_mapping';
|
||||
/**
|
||||
* Destination
|
||||
*
|
||||
|
|
@ -6072,6 +6180,10 @@ export type TransferCallConfig = {
|
|||
* Optional resolver that determines transfer routing at call time.
|
||||
*/
|
||||
resolver?: HttpTransferResolverConfig | null;
|
||||
/**
|
||||
* Optional gathered-context to external-PBX destination mapping.
|
||||
*/
|
||||
context_mapping?: ContextDestinationMappingConfig | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -6535,6 +6647,88 @@ export type ValidationError = {
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* VicidialAgentAPIConfiguration
|
||||
*
|
||||
* VICIdial remote-agent call-control API configuration.
|
||||
*/
|
||||
export type VicidialAgentApiConfiguration = {
|
||||
/**
|
||||
* Url
|
||||
*
|
||||
* Full URL to agc/api.php
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Username
|
||||
*
|
||||
* VICIdial agent API user
|
||||
*/
|
||||
username: string;
|
||||
/**
|
||||
* Password
|
||||
*
|
||||
* VICIdial agent API password
|
||||
*/
|
||||
password: string;
|
||||
/**
|
||||
* Source
|
||||
*
|
||||
* VICIdial API source tag
|
||||
*/
|
||||
source?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* VicidialExternalPBXConfiguration
|
||||
*
|
||||
* External-PBX configuration used by the VICIdial strategy adapter.
|
||||
*/
|
||||
export type VicidialExternalPbxConfiguration = {
|
||||
/**
|
||||
* Type
|
||||
*/
|
||||
type?: 'vicidial';
|
||||
agent_api: VicidialAgentApiConfiguration;
|
||||
non_agent_api?: VicidialNonAgentApiConfiguration | null;
|
||||
/**
|
||||
* Timeout Seconds
|
||||
*/
|
||||
timeout_seconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* VicidialNonAgentAPIConfiguration
|
||||
*
|
||||
* Optional VICIdial non-agent API configuration for lead updates.
|
||||
*/
|
||||
export type VicidialNonAgentApiConfiguration = {
|
||||
/**
|
||||
* Url
|
||||
*
|
||||
* Full non_agent_api.php URL
|
||||
*/
|
||||
url?: string | null;
|
||||
/**
|
||||
* Username
|
||||
*
|
||||
* Non-agent API user
|
||||
*/
|
||||
username?: string | null;
|
||||
/**
|
||||
* Password
|
||||
*
|
||||
* Non-agent API password
|
||||
*/
|
||||
password?: string | null;
|
||||
/**
|
||||
* Source
|
||||
*
|
||||
* Non-agent API source tag
|
||||
*/
|
||||
source?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* VobizConfigurationRequest
|
||||
*
|
||||
|
|
@ -6800,6 +6994,10 @@ export type WorkflowConfigurationDefaults = {
|
|||
* Context Compaction Enabled
|
||||
*/
|
||||
context_compaction_enabled?: boolean;
|
||||
/**
|
||||
* External Pbx Field Mappings
|
||||
*/
|
||||
external_pbx_field_mappings?: Array<ExternalPbxFieldMapping>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
|
@ -8709,10 +8907,14 @@ export type GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData = {
|
|||
query?: {
|
||||
/**
|
||||
* Page
|
||||
*
|
||||
* Page number (starts from 1)
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Limit
|
||||
*
|
||||
* Number of items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
|
|
@ -9980,10 +10182,14 @@ export type GetCampaignRunsApiV1CampaignCampaignIdRunsGetData = {
|
|||
query?: {
|
||||
/**
|
||||
* Page
|
||||
*
|
||||
* Page number (starts from 1)
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Limit
|
||||
*
|
||||
* Number of items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { OrganizationPreferences } from "@/client/types.gen";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
|
@ -20,6 +21,7 @@ import { useAuth } from "@/lib/auth";
|
|||
const emptyPreferences: OrganizationPreferences = {
|
||||
test_phone_number: "",
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
external_pbx_integrations_enabled: false,
|
||||
};
|
||||
|
||||
const timezoneSelectStyles = {
|
||||
|
|
@ -130,6 +132,8 @@ export function OrganizationPreferencesSection() {
|
|||
setPreferences({
|
||||
test_phone_number: nextPreferences.test_phone_number || "",
|
||||
timezone: nextPreferences.timezone || emptyPreferences.timezone,
|
||||
external_pbx_integrations_enabled:
|
||||
nextPreferences.external_pbx_integrations_enabled ?? false,
|
||||
});
|
||||
setTimezone(
|
||||
nextPreferences.timezone || emptyPreferences.timezone || "UTC",
|
||||
|
|
@ -151,6 +155,8 @@ export function OrganizationPreferencesSection() {
|
|||
body: {
|
||||
test_phone_number: preferences.test_phone_number || null,
|
||||
timezone: getTimezoneValue(timezone),
|
||||
external_pbx_integrations_enabled:
|
||||
preferences.external_pbx_integrations_enabled ?? false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -167,6 +173,8 @@ export function OrganizationPreferencesSection() {
|
|||
setPreferences({
|
||||
test_phone_number: result.data.test_phone_number || "",
|
||||
timezone: result.data.timezone || emptyPreferences.timezone,
|
||||
external_pbx_integrations_enabled:
|
||||
result.data.external_pbx_integrations_enabled ?? false,
|
||||
});
|
||||
setTimezone(result.data.timezone || emptyPreferences.timezone || "UTC");
|
||||
await refreshConfig();
|
||||
|
|
@ -212,6 +220,28 @@ export function OrganizationPreferencesSection() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="settings-external-pbx-integrations">
|
||||
External PBX integrations
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show and enable advanced external-PBX configuration for Asterisk,
|
||||
transfer tools, and workflows. Existing configuration is preserved
|
||||
when this is disabled.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="settings-external-pbx-integrations"
|
||||
checked={preferences.external_pbx_integrations_enabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
setPreferences({
|
||||
...preferences,
|
||||
external_pbx_integrations_enabled: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,45 @@ interface ConfigFormDialogProps {
|
|||
type FieldValue = string | number | boolean | undefined;
|
||||
type FieldValues = Record<string, FieldValue>;
|
||||
|
||||
function flattenValues(
|
||||
value: Record<string, unknown>,
|
||||
prefix = "",
|
||||
): FieldValues {
|
||||
const flattened: FieldValues = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (child && typeof child === "object" && !Array.isArray(child)) {
|
||||
Object.assign(flattened, flattenValues(child as Record<string, unknown>, path));
|
||||
} else if (
|
||||
child === undefined ||
|
||||
typeof child === "string" ||
|
||||
typeof child === "number" ||
|
||||
typeof child === "boolean"
|
||||
) {
|
||||
flattened[path] = child;
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function nestValues(values: FieldValues): Record<string, unknown> {
|
||||
const nested: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(values)) {
|
||||
if (value === undefined || value === "") continue;
|
||||
const parts = path.split(".");
|
||||
let current = nested;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const child = current[part];
|
||||
if (!child || typeof child !== "object" || Array.isArray(child)) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
export function ConfigFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -71,6 +110,15 @@ export function ConfigFormDialog({
|
|||
() => providers.find((p) => p.provider === providerName),
|
||||
[providers, providerName],
|
||||
);
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
currentProvider?.fields.filter(
|
||||
(field) =>
|
||||
!field.visible_when ||
|
||||
values[field.visible_when.field] === field.visible_when.equals,
|
||||
) ?? [],
|
||||
[currentProvider, values],
|
||||
);
|
||||
|
||||
// Fetch provider metadata once when the dialog opens.
|
||||
useEffect(() => {
|
||||
|
|
@ -88,7 +136,7 @@ export function ConfigFormDialog({
|
|||
setProviderName(existing.provider);
|
||||
setName(existing.name);
|
||||
setIsDefault(existing.is_default_outbound);
|
||||
setValues((existing.credentials ?? {}) as FieldValues);
|
||||
setValues(flattenValues(existing.credentials ?? {}));
|
||||
} else if (list.length > 0 && !providerName) {
|
||||
setProviderName(list[0].provider);
|
||||
setValues({});
|
||||
|
|
@ -106,7 +154,15 @@ export function ConfigFormDialog({
|
|||
}, [providerName, isEdit]);
|
||||
|
||||
const updateField = (fieldName: string, value: FieldValue) => {
|
||||
setValues((prev) => ({ ...prev, [fieldName]: value }));
|
||||
setValues((prev) => {
|
||||
const next = { ...prev, [fieldName]: value };
|
||||
if (value === undefined) {
|
||||
for (const field of currentProvider?.fields ?? []) {
|
||||
if (field.visible_when?.field === fieldName) delete next[field.name];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
|
|
@ -123,7 +179,7 @@ export function ConfigFormDialog({
|
|||
// Build the provider-discriminated config payload from collected values.
|
||||
const configPayload = {
|
||||
provider: providerName,
|
||||
...values,
|
||||
...nestValues(values),
|
||||
} as unknown as TelephonyConfigPayload;
|
||||
|
||||
if (isEdit && existing) {
|
||||
|
|
@ -253,8 +309,13 @@ export function ConfigFormDialog({
|
|||
|
||||
{currentProvider && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{currentProvider.fields.map((field) => (
|
||||
{visibleFields.map((field, index) => (
|
||||
<div className="space-y-1" key={field.name}>
|
||||
{field.section && field.section !== visibleFields[index - 1]?.section && (
|
||||
<div className="pb-2 pt-3">
|
||||
<h3 className="text-sm font-semibold">{field.section}</h3>
|
||||
</div>
|
||||
)}
|
||||
<Label htmlFor={`cfg-field-${field.name}`}>
|
||||
{field.label}
|
||||
{!field.required && (
|
||||
|
|
@ -345,6 +406,26 @@ function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
value={value === undefined ? "__none__" : String(value)}
|
||||
onValueChange={(next) => onChange(next === "__none__" ? undefined : next)}
|
||||
>
|
||||
<SelectTrigger id={`cfg-field-${field.name}`}>
|
||||
<SelectValue placeholder={placeholder || "Select an option"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!field.required && <SelectItem value="__none__">Not configured</SelectItem>}
|
||||
{(field.options ?? []).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={`cfg-field-${field.name}`}
|
||||
|
|
|
|||
92
ui/src/context/OrgConfigContext.test.tsx
Normal file
92
ui/src/context/OrgConfigContext.test.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OrgConfigProvider, useOrgConfig } from './OrgConfigContext';
|
||||
|
||||
const {
|
||||
getCurrentOrganizationContextMock,
|
||||
getPreferencesMock,
|
||||
getUserConfigurationsMock,
|
||||
useAuthMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getCurrentOrganizationContextMock: vi.fn(),
|
||||
getPreferencesMock: vi.fn(),
|
||||
getUserConfigurationsMock: vi.fn(),
|
||||
useAuthMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/client/sdk.gen', () => ({
|
||||
getCurrentOrganizationContextApiV1OrganizationsContextGet: getCurrentOrganizationContextMock,
|
||||
getPreferencesApiV1OrganizationsPreferencesGet: getPreferencesMock,
|
||||
getUserConfigurationsApiV1UserConfigurationsUserGet: getUserConfigurationsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/apiClient', () => ({
|
||||
createClientConfig: (config: unknown) => config,
|
||||
setupAuthInterceptor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
useAuth: useAuthMock,
|
||||
}));
|
||||
|
||||
function ContextState() {
|
||||
const { error, loading } = useOrgConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="loading">{String(loading)}</span>
|
||||
<span data-testid="error">{error?.message ?? ''}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('OrgConfigProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAuthMock.mockReturnValue({
|
||||
user: { id: 'user-1', provider: 'local' },
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
getAccessToken: vi.fn(async () => 'token'),
|
||||
redirectToLogin: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
provider: 'local',
|
||||
});
|
||||
getCurrentOrganizationContextMock.mockResolvedValue({
|
||||
data: {
|
||||
organization_id: 1,
|
||||
organization_provider_id: null,
|
||||
model_services: {
|
||||
config_source: 'empty',
|
||||
has_model_configuration_v2: false,
|
||||
managed_service_version: null,
|
||||
uses_managed_service_v2: false,
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
getUserConfigurationsMock.mockResolvedValue({
|
||||
data: {},
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces an HTTP error returned while loading organization preferences', async () => {
|
||||
getPreferencesMock.mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { detail: 'Preferences unavailable' },
|
||||
});
|
||||
|
||||
render(
|
||||
<OrgConfigProvider>
|
||||
<ContextState />
|
||||
</OrgConfigProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading').textContent).toBe('false');
|
||||
});
|
||||
expect(screen.getByTestId('error').textContent).toBe('Preferences unavailable');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,9 +3,10 @@
|
|||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet } from '@/client/sdk.gen';
|
||||
import type { OrganizationContextResponse, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getPreferencesApiV1OrganizationsPreferencesGet, getUserConfigurationsApiV1UserConfigurationsUserGet } from '@/client/sdk.gen';
|
||||
import type { OrganizationContextResponse, OrganizationPreferences, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
||||
import { detailFromError } from '@/lib/apiError';
|
||||
import type { AuthUser } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
|
|
@ -28,6 +29,8 @@ interface OrgConfigContextType {
|
|||
permissions: TeamPermission[];
|
||||
user: AuthUser | null;
|
||||
organizationPricing: OrganizationPricing | null;
|
||||
organizationPreferences: OrganizationPreferences | null;
|
||||
externalPbxIntegrationsEnabled: boolean;
|
||||
}
|
||||
|
||||
const OrgConfigContext = createContext<OrgConfigContextType | null>(null);
|
||||
|
|
@ -52,6 +55,7 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
|
||||
const [organizationPreferences, setOrganizationPreferences] = useState<OrganizationPreferences | null>(null);
|
||||
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
|
||||
|
||||
const auth = useAuth();
|
||||
|
|
@ -102,11 +106,16 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [orgContextResponse, userConfigResponse] = await Promise.all([
|
||||
const [orgContextResponse, userConfigResponse, preferencesResponse] = await Promise.all([
|
||||
getCurrentOrganizationContextApiV1OrganizationsContextGet(),
|
||||
getUserConfigurationsApiV1UserConfigurationsUserGet(),
|
||||
getPreferencesApiV1OrganizationsPreferencesGet(),
|
||||
]);
|
||||
|
||||
if (preferencesResponse.error) {
|
||||
throw new Error(detailFromError(preferencesResponse.error, 'Failed to load organization preferences'));
|
||||
}
|
||||
|
||||
if (orgContextResponse.data) {
|
||||
setOrgContext(orgContextResponse.data);
|
||||
}
|
||||
|
|
@ -116,6 +125,10 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
|||
setOrganizationPricing(pricingFromUserConfig(userConfigResponse.data));
|
||||
}
|
||||
|
||||
if (preferencesResponse.data) {
|
||||
setOrganizationPreferences(preferencesResponse.data);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch organization configuration'));
|
||||
|
|
@ -147,6 +160,9 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
|||
permissions,
|
||||
user: auth.user,
|
||||
organizationPricing,
|
||||
organizationPreferences,
|
||||
externalPbxIntegrationsEnabled:
|
||||
organizationPreferences?.external_pbx_integrations_enabled ?? false,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ export interface TranscriptConfiguration {
|
|||
include_end_timestamps: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalPBXFieldMapping {
|
||||
context_path: string;
|
||||
destination_field: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSCRIPT_CONFIGURATION: TranscriptConfiguration = {
|
||||
include_end_timestamps: false,
|
||||
};
|
||||
|
|
@ -110,6 +115,7 @@ type WorkflowConfigurationBase = Omit<
|
|||
| "turn_stop_strategy"
|
||||
| "dictionary"
|
||||
| "context_compaction_enabled"
|
||||
| "external_pbx_field_mappings"
|
||||
>;
|
||||
|
||||
export type WorkflowConfigurations = WorkflowConfigurationBase & {
|
||||
|
|
@ -125,6 +131,7 @@ export type WorkflowConfigurations = WorkflowConfigurationBase & {
|
|||
voicemail_detection?: VoicemailDetectionConfiguration;
|
||||
transcript_configuration: TranscriptConfiguration;
|
||||
context_compaction_enabled: boolean; // Summarize context on node transitions to remove stale tool calls
|
||||
external_pbx_field_mappings: ExternalPBXFieldMapping[];
|
||||
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
|
||||
model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override
|
||||
[key: string]: unknown; // Allow additional properties for future configurations
|
||||
|
|
@ -145,6 +152,7 @@ const FALLBACK_WORKFLOW_CONFIGURATIONS: WorkflowConfigurations = {
|
|||
dictionary: '',
|
||||
transcript_configuration: DEFAULT_TRANSCRIPT_CONFIGURATION,
|
||||
context_compaction_enabled: false,
|
||||
external_pbx_field_mappings: [],
|
||||
};
|
||||
|
||||
export function resolveWorkflowConfigurations(
|
||||
|
|
@ -196,6 +204,10 @@ export function resolveWorkflowConfigurations(
|
|||
configurations?.context_compaction_enabled
|
||||
?? defaults?.context_compaction_enabled
|
||||
?? FALLBACK_WORKFLOW_CONFIGURATIONS.context_compaction_enabled,
|
||||
external_pbx_field_mappings:
|
||||
configurations?.external_pbx_field_mappings
|
||||
?? defaults?.external_pbx_field_mappings
|
||||
?? FALLBACK_WORKFLOW_CONFIGURATIONS.external_pbx_field_mappings,
|
||||
transcript_configuration: {
|
||||
...DEFAULT_TRANSCRIPT_CONFIGURATION,
|
||||
...(defaults?.transcript_configuration as Partial<TranscriptConfiguration> | undefined),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue