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:
Abhishek 2026-07-20 21:57:34 +05:30 committed by GitHub
parent 2f7b47a1b4
commit d8cd34e8d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2886 additions and 102 deletions

View file

@ -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>
))}

View file

@ -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);
});
});

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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

View file

@ -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;
/**

View file

@ -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"}

View file

@ -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}`}

View 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');
});
});

View file

@ -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}

View file

@ -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),