feat: agent stream for cloudonix OPBX (#261)

* feat: agent stream for cloudonix OPBX

* feat: make cloudonix app name optional

* feat: create application while configuring telephony config

* fix: get telephony configuration from stamped workflow run

* fix: fix vobiz hangup URL
This commit is contained in:
Abhishek 2026-05-02 15:53:58 +05:30 committed by GitHub
parent 5cfdbeff02
commit 7fd3b96470
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1529 additions and 545 deletions

View file

@ -42,6 +42,7 @@ const edgeTypes = {
interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
workflowUuid?: string;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
@ -58,7 +59,7 @@ interface RenderWorkflowProps {
user: { id: string; email?: string };
}
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
const router = useRouter();
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
@ -303,6 +304,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
workflowUuid={workflowUuid}
saveWorkflow={guardedSaveWorkflow}
user={user}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}

View file

@ -1,7 +1,7 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Phone, Rocket } from "lucide-react";
import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Phone, Rocket } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useState } from "react";
@ -37,6 +37,7 @@ interface WorkflowEditorHeaderProps {
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
workflowUuid?: string;
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
onPhoneCallClick: () => void;
@ -63,6 +64,7 @@ export const WorkflowEditorHeader = ({
hasDraft,
onPublished,
workflowId,
workflowUuid,
}: WorkflowEditorHeaderProps) => {
const router = useRouter();
const { toggleSidebar } = useSidebar();
@ -123,6 +125,19 @@ export const WorkflowEditorHeader = ({
}
};
const handleCopyAgentUuid = async () => {
if (!workflowUuid) {
toast.error("Agent UUID not available");
return;
}
try {
await navigator.clipboard.writeText(workflowUuid);
toast.success("Agent UUID copied");
} catch {
toast.error("Failed to copy Agent UUID");
}
};
const handleDownloadWorkflow = () => {
if (!rfInstance.current) return;
@ -380,6 +395,14 @@ export const WorkflowEditorHeader = ({
<Download className="w-4 h-4 mr-2" />
Download Workflow
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleCopyAgentUuid}
disabled={!workflowUuid}
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
>
<Clipboard className="w-4 h-4 mr-2" />
Copy Agent UUID
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -81,6 +81,7 @@ export default function WorkflowDetailPage() {
<RenderWorkflow
initialWorkflowName={workflow.name}
workflowId={workflow.id}
workflowUuid={workflow.workflow_uuid ?? undefined}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],

View file

@ -1,7 +1,7 @@
"use client";
import { format } from "date-fns";
import { ArrowLeft, BookA, Brain, CalendarIcon, Download, ExternalLink, FileDown, 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, 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";
@ -81,6 +81,7 @@ const NAV_ITEMS = [
{ id: "recordings", label: "Recordings", icon: Mic },
{ id: "deployment", label: "Deployment", icon: Rocket },
{ id: "report", label: "Report", icon: FileDown },
{ id: "identity", label: "Agent UUID", icon: Fingerprint },
];
// ---------------------------------------------------------------------------
@ -992,6 +993,53 @@ function VoicemailSection({
);
}
// ---------------------------------------------------------------------------
// Section: Agent UUID
// ---------------------------------------------------------------------------
function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) {
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(workflowUuid);
toast.success("Agent UUID copied");
} catch {
toast.error("Failed to copy Agent UUID");
}
};
return (
<Card id="identity">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Fingerprint className="h-4 w-4" />
Agent UUID
</CardTitle>
<CardDescription>
Stable identifier for this agent. Used in agent-stream URLs and
other integrations where a numeric workflow ID isn&apos;t portable.
</CardDescription>
</CardHeader>
<CardContent>
<button
type="button"
onClick={handleCopy}
title="Click to copy"
className="group flex w-full items-center gap-2 rounded-md border bg-muted/20 p-2 text-left font-mono text-xs transition-colors hover:bg-muted/40"
>
<code className="flex-1 truncate">{workflowUuid}</code>
<Clipboard className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
</button>
</CardContent>
<CardFooter className="border-t pt-6">
<Button variant="outline" size="sm" onClick={handleCopy}>
<Clipboard className="h-3.5 w-3.5 mr-2" />
Copy UUID
</Button>
</CardFooter>
</Card>
);
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
@ -1263,6 +1311,11 @@ function WorkflowSettingsInner({
{/* Report */}
<ReportSection workflowId={workflowId} />
{/* Agent UUID */}
{workflow.workflow_uuid && (
<AgentUuidSection workflowUuid={workflow.workflow_uuid} />
)}
</>
)}
</div>

View file

@ -704,9 +704,9 @@ export type CloudonixConfigurationRequest = {
/**
* Application Name
*
* Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain.
* Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain. If omitted, an application is auto-created on save and its name is stored on the configuration.
*/
application_name: string;
application_name?: string | null;
/**
* From Numbers
*
@ -736,7 +736,7 @@ export type CloudonixConfigurationResponse = {
/**
* Application Name
*/
application_name: string;
application_name?: string | null;
/**
* From Numbers
*/
@ -2392,9 +2392,9 @@ export type PlivoConfigurationRequest = {
/**
* Application Id
*
* Plivo Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account.
* Plivo Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration.
*/
application_id: string;
application_id?: string | null;
/**
* From Numbers
*
@ -2424,7 +2424,7 @@ export type PlivoConfigurationResponse = {
/**
* Application Id
*/
application_id: string;
application_id?: string | null;
/**
* From Numbers
*/
@ -3388,9 +3388,9 @@ export type TelnyxConfigurationRequest = {
/**
* Connection Id
*
* Telnyx Call Control Application ID (connection_id)
* Telnyx Call Control Application ID (connection_id). If omitted, a Call Control Application is auto-created on save and its id is stored on the configuration.
*/
connection_id: string;
connection_id?: string | null;
/**
* From Numbers
*
@ -3416,7 +3416,7 @@ export type TelnyxConfigurationResponse = {
/**
* Connection Id
*/
connection_id: string;
connection_id?: string | null;
/**
* From Numbers
*/
@ -4125,9 +4125,9 @@ export type VobizConfigurationRequest = {
/**
* Application Id
*
* Vobiz Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account.
* Vobiz Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration.
*/
application_id: string;
application_id?: string | null;
/**
* From Numbers
*
@ -4157,7 +4157,7 @@ export type VobizConfigurationResponse = {
/**
* Application Id
*/
application_id: string;
application_id?: string | null;
/**
* From Numbers
*/
@ -4429,6 +4429,10 @@ export type WorkflowResponse = {
* Version Status
*/
version_status?: string | null;
/**
* Workflow Uuid
*/
workflow_uuid?: string | null;
};
/**

View file

@ -40,6 +40,26 @@ interface PhoneNumberDialogProps {
const NO_WORKFLOW = "__none__";
// Mirrors api/schemas/telephony_phone_number.py::_validate_address_shape and
// api/utils/telephony_address.py — keep in sync. Returns an error message
// when the address would normalize to a broken canonical form, or null when
// the input is acceptable.
const ADDRESS_FORMAT_STRIP_RE = /[\s\-()]/g;
const ADDRESS_E164_RE = /^\+\d{8,15}$/;
const ADDRESS_BARE_DIGITS_RE = /^\d{8,15}$/;
function validateAddress(rawAddress: string, countryCode: string): string | null {
const trimmed = rawAddress.trim();
if (!trimmed) return "Address is required";
if (/^sips?:/i.test(trimmed)) return null;
const stripped = trimmed.replace(ADDRESS_FORMAT_STRIP_RE, "");
if (ADDRESS_E164_RE.test(stripped)) return null;
if (ADDRESS_BARE_DIGITS_RE.test(stripped) && !countryCode.trim()) {
return "PSTN addresses without a leading '+' need a Country (ISO-2) hint, or include the country code in the address (e.g. +14155551234).";
}
return null;
}
export function PhoneNumberDialog({
open,
onOpenChange,
@ -58,6 +78,7 @@ export function PhoneNumberDialog({
const [inboundWorkflowId, setInboundWorkflowId] = useState<string>(NO_WORKFLOW);
const [workflows, setWorkflows] = useState<{ id: number; name: string }[]>([]);
const [submitting, setSubmitting] = useState(false);
const [addressTouched, setAddressTouched] = useState(false);
// Reset form when the dialog opens.
useEffect(() => {
@ -70,8 +91,12 @@ export function PhoneNumberDialog({
setInboundWorkflowId(
existing?.inbound_workflow_id ? String(existing.inbound_workflow_id) : NO_WORKFLOW,
);
setAddressTouched(false);
}, [open, existing]);
// Only validate the address on create — edits keep the immutable address.
const addressError = isEdit ? null : validateAddress(address, countryCode);
// Load workflows for the inbound dropdown.
useEffect(() => {
if (!open || !user) return;
@ -92,9 +117,13 @@ export function PhoneNumberDialog({
}, [open, user, getAccessToken]);
const handleSubmit = async () => {
if (!isEdit && !address.trim()) {
toast.error("Address is required");
return;
if (!isEdit) {
const err = validateAddress(address, countryCode);
if (err) {
setAddressTouched(true);
toast.error(err);
return;
}
}
setSubmitting(true);
try {
@ -174,8 +203,13 @@ export function PhoneNumberDialog({
placeholder="+19781899185, sip:101@asterisk.local, or 101"
value={address}
onChange={(e) => setAddress(e.target.value)}
onBlur={() => setAddressTouched(true)}
disabled={isEdit}
aria-invalid={addressTouched && !!addressError}
/>
{!isEdit && addressTouched && addressError && (
<p className="text-xs text-destructive">{addressError}</p>
)}
{isEdit && (
<p className="text-xs text-muted-foreground">
Address cannot be changed. Delete this number and create a new one to
@ -257,7 +291,10 @@ export function PhoneNumberDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
<Button
onClick={handleSubmit}
disabled={submitting || (!isEdit && !!addressError)}
>
{submitting ? "Saving..." : isEdit ? "Save changes" : "Add"}
</Button>
</DialogFooter>

View file

@ -1,6 +1,6 @@
'use client';
import { Archive, Eye, RotateCcw } from 'lucide-react';
import { Archive, Pencil, RotateCcw } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { toast } from 'sonner';
@ -33,7 +33,7 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
const [isPending, startTransition] = useTransition();
const [loadingWorkflowId, setLoadingWorkflowId] = useState<number | null>(null);
const handleView = (id: number) => {
const handleEdit = (id: number) => {
router.push(`/workflow/${id}`);
};
@ -108,11 +108,11 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
<Button
variant="outline"
size="sm"
onClick={() => handleView(workflow.id)}
onClick={() => handleEdit(workflow.id)}
className="flex items-center gap-2"
>
<Eye size={16} />
View
<Pencil size={16} />
Edit
</Button>
<Button
variant={showArchived ? "default" : "outline"}