Initial Commit 🚀 🚀

This commit is contained in:
Abhishek Kumar 2025-09-09 14:37:32 +05:30
commit 4f2a629340
444 changed files with 76863 additions and 0 deletions

View file

@ -0,0 +1,21 @@
import React, { ReactNode } from 'react'
import BaseHeader from '@/components/header/BaseHeader'
interface WorkflowLayoutProps {
children: ReactNode,
headerActions?: ReactNode,
backButton?: ReactNode,
showFeaturesNav?: boolean
}
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true }) => {
return (
<>
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
{children}
</>
)
}
export default WorkflowLayout

View file

@ -0,0 +1,149 @@
import '@xyflow/react/dist/style.css';
import {
Background,
Panel,
ReactFlow,
} from "@xyflow/react";
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
import WorkflowControls from "./components/WorkflowControls";
import WorkflowHeader from "./components/WorkflowHeader";
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
// Define the node types dynamically based on the onSave prop
const nodeTypes = {
[NodeType.START_CALL]: StartCall,
[NodeType.AGENT_NODE]: AgentNode,
[NodeType.END_CALL]: EndCall,
[NodeType.GLOBAL_NODE]: GlobalNode,
};
const edgeTypes = {
custom: CustomEdge,
};
interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
viewport: {
x: number;
y: number;
zoom: number;
};
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
}
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) {
const {
rfInstance,
nodes,
edges,
isAddNodePanelOpen,
workflowName,
isEditingName,
isDirty,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
setNodes,
setIsAddNodePanelOpen,
setIsEditingName,
handleNodeSelect,
handleNameChange,
saveWorkflow,
onConnect,
onEdgesChange,
onNodesChange,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations
} = useWorkflowState({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations });
const backButton = (
<Link href="/workflow">
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflows
</Button>
</Link>
);
const headerActions = (
<WorkflowHeader
workflowValidationErrors={workflowValidationErrors}
isDirty={isDirty}
workflowName={workflowName}
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
saveWorkflow={saveWorkflow}
/>
);
return (
<WorkflowProvider value={{ saveWorkflow }}>
<WorkflowLayout headerActions={headerActions} backButton={backButton} showFeaturesNav={false}>
<div className="h-[calc(100vh-80px)]">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={onConnect}
onInit={(instance) => {
rfInstance.current = instance;
}}
defaultEdgeOptions={{ animated: true, type: "custom" }}
>
<Background />
<Panel position="top-left">
<WorkflowControls
workflowId={workflowId}
workflowName={workflowName}
isEditingName={isEditingName}
setIsEditingName={setIsEditingName}
handleNameChange={handleNameChange}
setIsAddNodePanelOpen={setIsAddNodePanelOpen}
saveWorkflow={saveWorkflow}
nodes={nodes}
edges={edges}
setNodes={setNodes}
rfInstance={rfInstance}
templateContextVariables={templateContextVariables}
saveTemplateContextVariables={saveTemplateContextVariables}
workflowConfigurations={workflowConfigurations}
saveWorkflowConfigurations={saveWorkflowConfigurations}
/>
</Panel>
</ReactFlow>
</div>
<AddNodePanel
isOpen={isAddNodePanelOpen}
onNodeSelect={handleNodeSelect}
onClose={() => setIsAddNodePanelOpen(false)}
/>
</WorkflowLayout>
</WorkflowProvider>
);
}
export default RenderWorkflow;

View file

@ -0,0 +1,276 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { AmbientNoiseConfiguration, VADConfiguration, WorkflowConfigurations } from "@/types/workflow-configurations";
interface ConfigurationsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workflowConfigurations: WorkflowConfigurations | null;
onSave: (configurations: WorkflowConfigurations) => Promise<void>;
}
const DEFAULT_VAD_CONFIG: VADConfiguration = {
confidence: 0.7,
start_seconds: 0.4,
stop_seconds: 0.8,
minimum_volume: 0.6,
};
const DEFAULT_AMBIENT_NOISE_CONFIG: AmbientNoiseConfiguration = {
enabled: false,
volume: 0.3,
};
export const ConfigurationsDialog = ({
open,
onOpenChange,
workflowConfigurations,
onSave
}: ConfigurationsDialogProps) => {
const [vadConfig, setVadConfig] = useState<VADConfiguration>(
workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG
);
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG
);
const [maxCallDuration, setMaxCallDuration] = useState<number>(
workflowConfigurations?.max_call_duration || 600 // Default 10 minutes
);
const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState<number>(
workflowConfigurations?.max_user_idle_timeout || 10 // Default 10 seconds
);
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
setIsSaving(true);
try {
await onSave({
vad_configuration: vadConfig,
ambient_noise_configuration: ambientNoiseConfig,
max_call_duration: maxCallDuration,
max_user_idle_timeout: maxUserIdleTimeout
});
onOpenChange(false);
} catch (error) {
console.error("Failed to save configurations:", error);
} finally {
setIsSaving(false);
}
};
const handleDialogOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen);
if (isOpen) {
setVadConfig(workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG);
setAmbientNoiseConfig(workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG);
setMaxCallDuration(workflowConfigurations?.max_call_duration || 600);
setMaxUserIdleTimeout(workflowConfigurations?.max_user_idle_timeout || 10);
}
};
const handleVadChange = (field: keyof VADConfiguration, value: string) => {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
setVadConfig(prev => ({
...prev,
[field]: numValue
}));
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Configurations</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Voice Activity Detection Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Voice Activity Detection</h3>
<p className="text-xs text-gray-500">
Hyperparameters to set for voice activity detection. Already configured with defaults.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="confidence" className="text-xs">
Confidence
</Label>
<Input
id="confidence"
type="number"
step="0.1"
min="0"
max="1"
value={vadConfig.confidence}
onChange={(e) => handleVadChange('confidence', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="start_seconds" className="text-xs">
Start Seconds
</Label>
<Input
id="start_seconds"
type="number"
step="0.1"
min="0"
value={vadConfig.start_seconds}
onChange={(e) => handleVadChange('start_seconds', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="stop_seconds" className="text-xs">
Stop Seconds
</Label>
<Input
id="stop_seconds"
type="number"
step="0.1"
min="0"
value={vadConfig.stop_seconds}
onChange={(e) => handleVadChange('stop_seconds', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="minimum_volume" className="text-xs">
Minimum Volume
</Label>
<Input
id="minimum_volume"
type="number"
step="0.1"
min="0"
max="1"
value={vadConfig.minimum_volume}
onChange={(e) => handleVadChange('minimum_volume', e.target.value)}
/>
</div>
</div>
</div>
{/* Ambient Noise Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Ambient Noise</h3>
<p className="text-xs text-gray-500">
Add background office ambient noise to make the conversation sound more natural.
</p>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="ambient-noise-enabled" className="text-sm">
Use Ambient Noise
</Label>
<Switch
id="ambient-noise-enabled"
checked={ambientNoiseConfig.enabled}
onCheckedChange={(checked) =>
setAmbientNoiseConfig(prev => ({ ...prev, enabled: checked }))
}
/>
</div>
{ambientNoiseConfig.enabled && (
<div className="space-y-2">
<Label htmlFor="ambient-volume" className="text-xs">
Volume
</Label>
<Input
id="ambient-volume"
type="number"
step="0.1"
min="0"
max="1"
value={ambientNoiseConfig.volume}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) {
setAmbientNoiseConfig(prev => ({ ...prev, volume: value }));
}
}}
/>
</div>
)}
</div>
</div>
{/* Call Management Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Call Management</h3>
<p className="text-xs text-gray-500">
Configure call duration limits and idle timeout settings.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max_call_duration" className="text-xs">
Max Call Duration (seconds)
</Label>
<Input
id="max_call_duration"
type="number"
step="1"
min="1"
value={maxCallDuration}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) {
setMaxCallDuration(value);
}
}}
/>
<p className="text-xs text-gray-500">Default: 600 (10 minutes)</p>
</div>
<div className="space-y-2">
<Label htmlFor="max_user_idle_timeout" className="text-xs">
Max User Idle Timeout (seconds)
</Label>
<Input
id="max_user_idle_timeout"
type="number"
step="1"
min="1"
value={maxUserIdleTimeout}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value > 0) {
setMaxUserIdleTimeout(value);
}
}}
/>
<p className="text-xs text-gray-500">Default: 10 seconds</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,137 @@
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface TemplateContextVariablesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templateContextVariables: Record<string, string>;
onSave: (variables: Record<string, string>) => Promise<void>;
}
export const TemplateContextVariablesDialog = ({
open,
onOpenChange,
templateContextVariables,
onSave
}: TemplateContextVariablesDialogProps) => {
const [contextVars, setContextVars] = useState<Record<string, string>>(templateContextVariables);
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const handleAddContextVar = () => {
if (newKey && newValue) {
setContextVars(prev => ({ ...prev, [newKey]: newValue }));
}
setNewKey("");
setNewValue("");
};
const handleRemoveContextVar = (key: string) => {
setContextVars(prev => {
const newVars = { ...prev };
delete newVars[key];
return newVars;
});
};
const handleSave = async () => {
let varsToSave = contextVars;
// Include any newly typed key/value that hasn't been added via the "Add Variable" button
if (newKey && newValue) {
varsToSave = { ...varsToSave, [newKey]: newValue };
}
await onSave(varsToSave);
onOpenChange(false);
};
const handleDialogOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen);
if (isOpen) {
setContextVars(templateContextVariables);
setNewKey("");
setNewValue("");
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Template Context Variables</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Existing Variables */}
{Object.entries(contextVars).length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Current Variables</Label>
{Object.entries(contextVars).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 p-2 border rounded-md">
<div className="flex-1">
<div className="text-sm font-medium">{key}</div>
<div className="text-xs text-gray-500 truncate">{value}</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveContextVar(key)}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
{/* Add New Variable */}
<div className="space-y-3">
<Label className="text-sm font-medium">Add New Variable</Label>
<div className="space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="key" className="text-xs">Key</Label>
<Input
id="key"
placeholder="Enter variable key"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
/>
</div>
<div className="flex-1">
<Label htmlFor="value" className="text-xs">Value</Label>
<Input
id="value"
placeholder="Enter variable value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
/>
</div>
</div>
<Button
size="sm"
onClick={handleAddContextVar}
disabled={!newKey || !newValue}
>
Add Variable
</Button>
</div>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Variables
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,178 @@
import dagre from '@dagrejs/dagre';
import { ReactFlowInstance } from "@xyflow/react";
import { Check, Pencil } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { WorkflowConfigurations } from "@/types/workflow-configurations";
import { ConfigurationsDialog } from "./ConfigurationsDialog";
import { TemplateContextVariablesDialog } from "./TemplateContextVariablesDialog";
interface WorkflowControlsProps {
workflowId: number;
workflowName: string;
isEditingName: boolean;
setIsEditingName: (isEditing: boolean) => void;
handleNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
setIsAddNodePanelOpen: (isOpen: boolean) => void;
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>;
nodes: FlowNode[];
edges: FlowEdge[];
setNodes: (nodes: FlowNode[] | ((nds: FlowNode[]) => FlowNode[])) => void;
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
templateContextVariables?: Record<string, string>;
saveTemplateContextVariables: (variables: Record<string, string>) => Promise<void>;
workflowConfigurations: WorkflowConfigurations | null;
saveWorkflowConfigurations: (configurations: WorkflowConfigurations) => Promise<void>;
}
export const layoutNodes = (
nodes: FlowNode[],
edges: FlowEdge[],
rankdir: 'TB' | 'LR',
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>,
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>
) => {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir, nodesep: 250, ranksep: 250 });
g.setDefaultEdgeLabel(() => ({}));
// Sort nodes so startCall nodes come first and endCall nodes come last
const sortedNodes = [...nodes].sort((a, b) => {
if (a.type === 'startCall') return -1;
if (b.type === 'startCall') return 1;
if (a.type === 'endCall') return 1;
if (b.type === 'endCall') return -1;
return 0;
});
sortedNodes.forEach((node) => {
g.setNode(node.id, { width: 180, height: 60 });
});
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
const newNodes = sortedNodes.map((node) => {
const nodeWithPosition = g.node(node.id);
return {
...node,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
};
});
// Fit view to the new layout and save the viewport position
setTimeout(() => {
rfInstance.current?.fitView();
saveWorkflow(true);
}, 0);
return newNodes;
};
const WorkflowControls = ({
workflowId,
workflowName,
isEditingName,
setIsEditingName,
handleNameChange,
setIsAddNodePanelOpen,
saveWorkflow,
nodes,
edges,
setNodes,
rfInstance,
templateContextVariables = {},
saveTemplateContextVariables,
workflowConfigurations,
saveWorkflowConfigurations
}: WorkflowControlsProps) => {
const router = useRouter();
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
return (
<div>
<div className="mb-2">
<div className="flex items-center relative bg-white border border-gray-200 rounded-md px-3 py-1 shadow-sm group hover:border-gray-300 transition-colors w-45">
{isEditingName ? (
<input
type="text"
value={workflowName}
onChange={handleNameChange}
className="pr-8 bg-transparent focus:outline-none w-full text-lg"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && (setIsEditingName(false), saveWorkflow(false))}
/>
) : (
<h1 className="text-lg font-medium pr-8 truncate">{workflowName}</h1>
)}
<Button
size="icon"
variant="ghost"
onClick={() => {
if (isEditingName) {
setIsEditingName(false);
saveWorkflow(false);
} else {
setIsEditingName(true);
}
}}
className="h-7 w-7 absolute right-2 top-1/2 transform -translate-y-1/2"
>
{isEditingName ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Pencil className="h-4 w-4 opacity-50 group-hover:opacity-100 transition-opacity" />
)}
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Button onClick={() => setIsAddNodePanelOpen(true)}>Add New Node</Button>
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'TB', rfInstance, saveWorkflow))}>Vertical Layout</Button>
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}>Horizontal Layout</Button>
<Button
onClick={() => setIsConfigurationsDialogOpen(true)}
className="flex items-center gap-2"
>
Configurations
</Button>
<Button
onClick={() => setIsContextVarsDialogOpen(true)}
className="flex items-center gap-2"
>
Template Context Variables
</Button>
<Button
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
className="flex items-center gap-1"
>
View Run History
</Button>
</div>
<ConfigurationsDialog
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
onSave={saveWorkflowConfigurations}
/>
<TemplateContextVariablesDialog
open={isContextVarsDialogOpen}
onOpenChange={setIsContextVarsDialogOpen}
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
</div>
);
};
export default WorkflowControls;

View file

@ -0,0 +1,321 @@
import 'react-international-phone/style.css';
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
import { useEffect,useState } from "react";
import { PhoneInput } from 'react-international-phone';
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
import { WorkflowError } from '@/client/types.gen';
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useUserConfig } from "@/context/UserConfigContext";
import { useAuth } from '@/lib/auth';
interface WorkflowHeaderProps {
isDirty: boolean;
workflowName: string;
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
workflowValidationErrors: WorkflowError[];
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
}
const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject<FlowNode, FlowEdge> | undefined) => {
if (!workflow_definition) return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
const exportData = {
name: workflow_name,
workflow_definition: workflow_definition
};
// Convert to JSON string with proper formatting
const jsonString = JSON.stringify(exportData, null, 2);
// Create a blob with the JSON data
const blob = new Blob([jsonString], { type: 'application/json' });
// Create a download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${workflow_name.replace(/\s+/g, '_')}.json`;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
const { userConfig, saveUserConfig } = useUserConfig();
const [dialogOpen, setDialogOpen] = useState(false);
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const [saving, setSaving] = useState(false);
const [savingWorkflow, setSavingWorkflow] = useState(false);
const [callLoading, setCallLoading] = useState(false);
const [callError, setCallError] = useState<string | null>(null);
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
const [phoneChanged, setPhoneChanged] = useState(false);
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
const { user, getAccessToken } = useAuth();
const hasValidationErrors = workflowValidationErrors.length > 0;
// Reset call-related state whenever the dialog is closed so that a new call can be placed
useEffect(() => {
if (!dialogOpen) {
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
}
}, [dialogOpen]);
// Keep phoneNumber in sync with userConfig when dialog opens
const handleDialogOpenChange = (open: boolean) => {
setDialogOpen(open);
if (open) {
setPhoneNumber(userConfig?.test_phone_number || "");
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
setSaving(false);
}
};
const handlePhoneInputChange = (
formattedValue: string
) => {
// `value` is the raw E.164 value, e.g. "+14155552671"
setPhoneNumber(formattedValue);
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
// clear any prior errors, etc.
setCallError(null);
setCallSuccessMsg(null);
};
const handleSavePhone = async () => {
if (!userConfig) return;
setSaving(true);
try {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
} catch (err: unknown) {
setCallError(err instanceof Error ? err.message : "Failed to save phone number");
} finally {
setSaving(false);
}
};
const handleStartCall = async () => {
setCallLoading(true);
setCallError(null);
setCallSuccessMsg(null);
try {
if (!user) return;
const accessToken = await getAccessToken();
const response = await initiateCallApiV1TwilioInitiateCallPost({
body: { workflow_id: workflowId },
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
let errMsg = "Failed to initiate call";
if (typeof response.error === "string") {
errMsg = response.error;
} else if (response.error && typeof response.error === "object") {
errMsg = (response.error as unknown as { detail: string }).detail || JSON.stringify(response.error);
}
setCallError(errMsg);
} else {
// Try to show a message from the response, fallback to generic
const msg = response.data && (response.data as unknown as { message: string }).message || "Call initiated successfully!";
setCallSuccessMsg(typeof msg === "string" ? msg : JSON.stringify(msg));
}
} catch (err: unknown) {
setCallError(err instanceof Error ? err.message : "Failed to initiate call");
} finally {
setCallLoading(false);
}
};
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-sm text-gray-500 mr-2">
{hasValidationErrors ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<ShieldCheck className="h-4 w-4 text-green-500" />
)}
<span>{hasValidationErrors ? 'Invalid' : 'Valid'}</span>
{hasValidationErrors && (
<Button
size="sm"
className="ml-1 h-6 px-2 text-xs"
onClick={() => setValidationDialogOpen(true)}
>
View Issues
</Button>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{hasValidationErrors
? `Workflow has ${workflowValidationErrors.length} validation ${workflowValidationErrors.length === 1 ? 'issue' : 'issues'}`
: 'Workflow is valid'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
>
<Download className="mr-2 h-4 w-4" />
Export Pathway
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onRun("smallwebrtc")} // Don't change the mode since its defined in the database enum
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Web Call
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Phone Call
</Button>
{isDirty ? (
<Button
variant="default"
size="sm"
onClick={async () => {
setSavingWorkflow(true);
await saveWorkflow();
setSavingWorkflow(false);
}}
disabled={savingWorkflow}
className="animate-pulse"
>
{savingWorkflow ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
) : (
<div className="flex items-center gap-1 text-sm text-gray-500">
<CheckCheck className="h-4 w-4 text-green-500" />
<span className='mr-2'>Saved</span>
</div>
)}
{/* Validation Errors Dialog */}
<Dialog open={validationDialogOpen} onOpenChange={setValidationDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Workflow Validation Issues</DialogTitle>
<DialogDescription>
Please fix the following issues before running the workflow.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
<ul className="space-y-2">
{workflowValidationErrors.map((error, index) => (
<li key={index} className="border-l-2 border-red-500 pl-3 py-2">
<div className="font-medium">{error.message}</div>
{error.id && (
<div className="text-sm text-gray-500">
{error.kind === 'node' ? 'Node' : error.kind === 'edge' ? 'Edge' : 'Workflow'} ID: {error.id}
</div>
)}
{error.field && (
<div className="text-sm mt-1">
Field: {error.field}
</div>
)}
</li>
))}
</ul>
</div>
<DialogFooter>
<Button onClick={() => setValidationDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Phone Call Dialog */}
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Phone Call</DialogTitle>
<DialogDescription>
Enter the phone number to call. This will be saved to your user config.
</DialogDescription>
</DialogHeader>
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
{phoneChanged && (
<Button
variant="outline"
size="sm"
onClick={handleSavePhone}
disabled={saving}
>
{saving ? "Saving..." : "Save Number"}
</Button>
)}
<DialogFooter>
{!callSuccessMsg ? (
<Button
onClick={handleStartCall}
disabled={callLoading || phoneChanged || !phoneNumber || saving}
>
{callLoading ? "Calling..." : "Start Call"}
</Button>
) : (
<Button onClick={() => setDialogOpen(false)}>
Close
</Button>
)}
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
</DialogFooter>
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
</DialogContent>
</Dialog>
</div>
);
};
export default WorkflowHeader;

View file

@ -0,0 +1,2 @@
export * from './WorkflowControls';
export * from './WorkflowHeader';

View file

@ -0,0 +1,17 @@
import { createContext, useContext } from 'react';
interface WorkflowContextType {
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
}
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);
export const WorkflowProvider = WorkflowContext.Provider;
export const useWorkflow = () => {
const context = useContext(WorkflowContext);
if (!context) {
throw new Error('useWorkflow must be used within a WorkflowProvider');
}
return context;
};

View file

@ -0,0 +1 @@
export * from './useWorkflowState';

View file

@ -0,0 +1,446 @@
import {
applyEdgeChanges,
applyNodeChanges,
OnConnect,
OnEdgesChange,
OnNodesChange,
ReactFlowInstance,
useEdgesState,
useNodesState
} from "@xyflow/react";
import { addEdge } from "@xyflow/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
updateWorkflowApiV1WorkflowWorkflowIdPut,
validateWorkflowApiV1WorkflowWorkflowIdValidatePost
} from "@/client";
import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { getRandomId } from "@/lib/utils";
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from "@/types/workflow-configurations";
export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean {
switch (type) {
case NodeType.AGENT_NODE:
return true; // Agents can be interrupted
case NodeType.START_CALL:
case NodeType.END_CALL:
return false; // Start/End messages should not be interrupted
default:
return false;
}
}
const defaultNodes: FlowNode[] = [
{
id: "1",
type: NodeType.START_CALL,
position: { x: 200, y: 200 },
data: {
prompt: "",
name: "",
allow_interrupt: getDefaultAllowInterrupt(NodeType.START_CALL),
},
},
];
const getNewNode = (type: string, position: { x: number, y: number }) => {
return {
id: `${getRandomId()}`,
type,
position,
data: {
prompt: {
[NodeType.GLOBAL_NODE]: "You are a helpful assistant whose mode of interaction with the user is voice. So don't use any special characters which can not be pronounced. Use short sentences and simple language.",
}[type] || "",
name: {
[NodeType.GLOBAL_NODE]: "Global Node",
[NodeType.START_CALL]: "Start Call",
[NodeType.END_CALL]: "End Call",
}[type] || "",
allow_interrupt: getDefaultAllowInterrupt(type),
},
};
};
interface UseWorkflowStateProps {
initialWorkflowName: string;
workflowId: number;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
viewport: {
x: number;
y: number;
zoom: number;
};
};
initialTemplateContextVariables?: Record<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
}
export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: UseWorkflowStateProps) => {
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
const router = useRouter();
const { user, getAccessToken } = useAuth();
const [nodes, setNodes] = useNodesState(
initialFlow?.nodes?.length
? initialFlow.nodes.map(node => ({
...node,
data: {
...node.data,
invalid: false,
allow_interrupt: node.data.allow_interrupt !== undefined
? node.data.allow_interrupt
: getDefaultAllowInterrupt(node.type),
}
}))
: defaultNodes
);
const [edges, setEdges] = useEdgesState(initialFlow?.edges ?? []);
const [isAddNodePanelOpen, setIsAddNodePanelOpen] = useState(false);
const [workflowName, setWorkflowName] = useState(initialWorkflowName);
const [isEditingName, setIsEditingName] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [workflowValidationErrors, setWorkflowValidationErrors] = useState<WorkflowError[]>([]);
const [templateContextVariables, setTemplateContextVariables] = useState<Record<string, string>>(
initialTemplateContextVariables || {}
);
const [workflowConfigurations, setWorkflowConfigurations] = useState<WorkflowConfigurations | null>(
initialWorkflowConfigurations || DEFAULT_WORKFLOW_CONFIGURATIONS
);
const handleNodeSelect = useCallback((nodeType: string) => {
/*
Used to add new node to the workflow. Receives nodeType as param.
Example: nodeType can be agentNode/ startNode etc. as defined by NodeType in
types.ts
We then pass nodeTypes which contais the NodeType keyword and the component.
Those components then contain all the component speecific functioanlity like edit
button etc.
*/
const newNode = getNewNode(nodeType, { x: 150, y: 150 });
setNodes((nds) => [...nds, newNode]);
setIsAddNodePanelOpen(false);
}, [setNodes, setIsAddNodePanelOpen]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setWorkflowName(e.target.value);
setIsDirty(true);
};
// Validate workflow function (without saving)
const validateWorkflow = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
path: {
workflow_id: workflowId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Reset validation state for all nodes and edges
setNodes((nds) => nds.map(node => ({ ...node, data: { ...node.data, invalid: false, validationMessage: null } })));
setEdges((eds) => eds.map(edge => ({ ...edge, data: { ...edge.data, invalid: false, validationMessage: null } })));
setWorkflowValidationErrors([]);
// Check if we have a 422 error with validation errors
if (response.error) {
// The error could be in different formats depending on the status code
let errors: WorkflowError[] = [];
// Type assertion for validation response structure
const errorResponse = response.error as {
is_valid?: boolean;
errors?: WorkflowError[];
detail?: { errors: WorkflowError[] };
};
// For 422 responses, the error contains the validation response
if (errorResponse.is_valid === false && errorResponse.errors) {
errors = errorResponse.errors;
}
// Also check for detail.errors format
else if (errorResponse.detail?.errors) {
errors = errorResponse.detail.errors;
}
if (errors.length > 0) {
// Update nodes with validation state
setNodes((nds) => nds.map(node => {
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
if (nodeErrors.length > 0) {
return {
...node,
data: {
...node.data,
invalid: true,
validationMessage: nodeErrors.map(err => err.message).join(', ')
}
};
}
return node;
}));
// Update edges with validation state
setEdges((eds) => eds.map(edge => {
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
if (edgeErrors.length > 0) {
return {
...edge,
data: {
...edge.data,
invalid: true,
validationMessage: edgeErrors.map(err => err.message).join(', ')
}
};
}
return edge;
}));
// Set workflow validation errors (all types of errors)
setWorkflowValidationErrors(errors);
}
} else if (response.data) {
// If we get a 200 response with data, check if it's valid
if (response.data.is_valid === false && response.data.errors) {
const errors = response.data.errors;
// Update nodes with validation state
setNodes((nds) => nds.map(node => {
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
if (nodeErrors.length > 0) {
return {
...node,
data: {
...node.data,
invalid: true,
validationMessage: nodeErrors.map((err) => err.message).join(', ')
}
};
}
return node;
}));
// Update edges with validation state
setEdges((eds) => eds.map(edge => {
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
if (edgeErrors.length > 0) {
return {
...edge,
data: {
...edge.data,
invalid: true,
validationMessage: edgeErrors.map((err) => err.message).join(', ')
}
};
}
return edge;
}));
// Set workflow validation errors (all types of errors)
setWorkflowValidationErrors(errors);
} else {
logger.info('Workflow is valid');
}
}
} catch (error) {
logger.error(`Unexpected validation error: ${error}`);
}
}, [workflowId, user, getAccessToken, setNodes, setEdges]);
// Save function
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
/*
validates and saves workflow
*/
if (!user || !rfInstance.current) return;
const flow = rfInstance.current.toObject();
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
body: {
name: workflowName,
workflow_definition: updateWorkflowDefinition ? flow : null,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setIsDirty(false);
} catch (error) {
logger.error(`Error auto-saving workflow: ${error}`);
}
// Validate after saving
await validateWorkflow();
}, [workflowId, workflowName, setIsDirty, user, getAccessToken, rfInstance, validateWorkflow]);
// Handle debounced save - REMOVED AUTOSAVE FUNCTIONALITY
// const debouncedSave = useCallback(() => {
// // Clear any existing timeout
// if (saveTimeoutRef.current) {
// clearTimeout(saveTimeoutRef.current);
// }
// // Set a new timeout
// saveTimeoutRef.current = setTimeout(() => {
// saveWorkflow();
// saveTimeoutRef.current = null;
// }, 2000);
// }, [saveWorkflow]);
const onConnect: OnConnect = useCallback((connection) => {
setEdges((eds) => addEdge({
...connection,
data: {
label: '',
condition: ''
}
}, eds));
setIsDirty(true);
// Trigger validation after connection
setTimeout(() => validateWorkflow(), 100);
}, [setEdges, validateWorkflow]);
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => {
const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
setIsDirty(true);
// Trigger validation after edge changes
setTimeout(() => validateWorkflow(), 100);
return newEdges;
}),
[setEdges, validateWorkflow],
);
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => {
const newNodes = applyNodeChanges(changes, nds) as FlowNode[];
setIsDirty(true);
// Trigger validation after node changes
setTimeout(() => validateWorkflow(), 100);
return newNodes;
}),
[setNodes, validateWorkflow],
);
const onRun = async (mode: string) => {
if (!user) return;
const workflowRunName = `WR-${getRandomId()}`;
const accessToken = await getAccessToken();
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
workflow_id: workflowId,
},
body: {
mode,
name: workflowRunName
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
router.push(`/workflow/${workflowId}/run/${response.data?.id}`);
};
// Save template context variables function
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
if (!user) return;
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
body: {
name: workflowName,
workflow_definition: null,
template_context_variables: variables,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setTemplateContextVariables(variables);
logger.info('Template context variables saved successfully');
} catch (error) {
logger.error(`Error saving template context variables: ${error}`);
throw error;
}
}, [workflowId, workflowName, user, getAccessToken]);
// Save workflow configurations function
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => {
if (!user) return;
const accessToken = await getAccessToken();
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
body: {
name: workflowName,
workflow_definition: null,
workflow_configurations: configurations as Record<string, unknown>,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setWorkflowConfigurations(configurations);
logger.info('Workflow configurations saved successfully');
} catch (error) {
logger.error(`Error saving workflow configurations: ${error}`);
throw error;
}
}, [workflowId, workflowName, user, getAccessToken]);
// Validate workflow on mount
useEffect(() => {
validateWorkflow();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Removed useEffect for clearing auto-save timeout as autosave is disabled
return {
rfInstance,
nodes,
edges,
isAddNodePanelOpen,
workflowName,
isEditingName,
isDirty,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
setNodes,
setEdges,
setIsAddNodePanelOpen,
setWorkflowName,
setIsEditingName,
handleNodeSelect,
handleNameChange,
saveWorkflow,
onConnect,
onEdgesChange,
onNodesChange,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations
};
};

View file

@ -0,0 +1,91 @@
'use client';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow';
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from '@/client/sdk.gen';
import type { WorkflowResponse } from '@/client/types.gen';
import { FlowEdge, FlowNode } from '@/components/flow/types';
import SpinLoader from '@/components/SpinLoader';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations';
import WorkflowLayout from '../WorkflowLayout';
export default function WorkflowDetailPage() {
const params = useParams();
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth();
// Redirect if not authenticated
useEffect(() => {
if (!authLoading && !user) {
redirectToLogin();
}
}, [authLoading, user, redirectToLogin]);
useEffect(() => {
const fetchWorkflow = async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: {
workflow_id: Number(params.workflowId)
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const workflow = response.data;
setWorkflow(workflow);
} catch (err) {
setError('Failed to fetch workflow');
logger.error(`Error fetching workflow: ${err}`);
} finally {
setLoading(false);
}
};
if (user) {
fetchWorkflow();
}
}, [params.workflowId, user, getAccessToken]);
if (loading) {
return (
<WorkflowLayout>
<SpinLoader />
</WorkflowLayout>
);
}
else if (error || !workflow) {
return (
<WorkflowLayout showFeaturesNav={false}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg text-red-500">{error || 'Workflow not found'}</div>
</div>
</WorkflowLayout>
);
}
else {
return (
// We are sending custom header actions to WorkflowLayout from RenderWorkflow component
<RenderWorkflow
initialWorkflowName={workflow.name}
workflowId={workflow.id}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 1 }
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
/>
);
}
}

View file

@ -0,0 +1,114 @@
import { useRouter } from "next/navigation";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
ContextVariablesSection,
WorkflowConfigErrorDialog
} from "./components";
import { useWebRTC } from "./hooks";
const Pipecat = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
accessToken: string | null,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
iceGatheringState,
iceConnectionState,
start,
stop,
isStarting,
initialContext,
setInitialContext
} = useWebRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
const navigateToApiKeys = () => {
router.push('/api-keys');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
return (
<>
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle>Workflow Run</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4">
<>
<ContextVariablesSection
initialContext={initialContext}
setInitialContext={setInitialContext}
disabled={connectionActive || isCompleted}
/>
<AudioControls
audioInputs={audioInputs}
selectedAudioInput={selectedAudioInput}
setSelectedAudioInput={setSelectedAudioInput}
isCompleted={isCompleted}
connectionActive={connectionActive}
permissionError={permissionError}
start={start}
stop={stop}
isStarting={isStarting}
/>
<ConnectionStatus
iceGatheringState={iceGatheringState}
iceConnectionState={iceConnectionState}
/>
</>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<p className="text-xs text-muted-foreground">
WebRTC connection status: {connectionActive ? 'Active' : 'Inactive'}
</p>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</CardFooter>
</Card>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
onNavigateToApiKeys={navigateToApiKeys}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={navigateToWorkflow}
/>
</>
);
};
export default Pipecat;

View file

@ -0,0 +1,34 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface ApiKeyErrorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
error: string | null;
onNavigateToApiKeys: () => void;
}
export const ApiKeyErrorDialog = ({
open,
onOpenChange,
error,
onNavigateToApiKeys
}: ApiKeyErrorDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key Error</DialogTitle>
<DialogDescription className="text-red-500 whitespace-pre-line">
{error}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={onNavigateToApiKeys}>
Go to API Keys Settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,103 @@
import { Mic, MicOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface AudioControlsProps {
audioInputs: MediaDeviceInfo[];
selectedAudioInput: string;
setSelectedAudioInput: (deviceId: string) => void;
isCompleted: boolean;
connectionActive: boolean;
permissionError: string | null;
start: () => Promise<void>;
stop: () => void;
isStarting: boolean;
}
export const AudioControls = ({
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
isCompleted,
connectionActive,
permissionError,
start,
stop,
isStarting
}: AudioControlsProps) => {
// Check if we have valid audio devices (permissions granted)
const hasValidDevices = audioInputs.length > 0 && audioInputs.some(device => device.deviceId && device.deviceId.trim() !== '');
const validAudioInputs = audioInputs.filter(device => device.deviceId && device.deviceId.trim() !== '');
const requestAudioPermissions = async () => {
try {
await navigator.mediaDevices.getUserMedia({ audio: true });
// This will trigger the parent component to refresh the device list
window.location.reload();
} catch (error) {
console.error('Failed to request audio permissions:', error);
}
};
return (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">Audio Input</h3>
{!hasValidDevices ? (
<div className="space-y-3">
<div className="flex items-center space-x-2 text-amber-600 bg-amber-50 p-3 rounded-md border border-amber-200">
<MicOff className="h-4 w-4" />
<span className="text-sm">Audio permissions are required to start the call</span>
</div>
<Button
onClick={requestAudioPermissions}
variant="outline"
className="w-full"
>
<Mic className="h-4 w-4 mr-2" />
Grant Audio Permissions
</Button>
</div>
) : (
<Select value={selectedAudioInput} onValueChange={setSelectedAudioInput}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select audio input" />
</SelectTrigger>
<SelectContent>
{validAudioInputs.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `Audio Device #${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isCompleted && (
<div className="flex items-center space-x-4">
<p className="text-red-500">
Workflow run completed. Please refresh the page in a while to see the recording and transcript.
</p>
</div>
)}
{!isCompleted && hasValidDevices && (
<div className="flex items-center space-x-4">
{!connectionActive ? (
<Button onClick={start} disabled={isStarting}>
{isStarting ? 'Starting...' : 'Start'}
</Button>
) : (
<Button onClick={stop} variant="destructive">Stop</Button>
)}
{permissionError && (
<p className="text-red-500">{permissionError}</p>
)}
</div>
)}
</>
);
};

View file

@ -0,0 +1,22 @@
interface ConnectionStatusProps {
iceGatheringState: string;
iceConnectionState: string;
}
export const ConnectionStatus = ({
iceGatheringState,
iceConnectionState
}: ConnectionStatusProps) => {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">ICE gathering state</h3>
<p className="text-sm text-muted-foreground">{iceGatheringState}</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">ICE connection state</h3>
<p className="text-sm text-muted-foreground">{iceConnectionState}</p>
</div>
</div>
);
};

View file

@ -0,0 +1,43 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ContextDisplayProps {
title: string;
context: Record<string, string | number | boolean | object> | null;
}
export const ContextDisplay = ({ title, context }: ContextDisplayProps) => {
if (!context || Object.keys(context).length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No {title.toLowerCase()} available</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(context).map(([key, value]) => (
<div key={key} className="space-y-1">
<label className="text-sm font-medium text-gray-700">
{key}
</label>
<div className="p-3 bg-gray-50 border rounded-md">
<p className="text-sm text-gray-900 whitespace-pre-wrap">
{typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || 'No value')}
</p>
</div>
</div>
))}
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,109 @@
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface ContextVariablesSectionProps {
initialContext: Record<string, string>;
setInitialContext: (variables: Record<string, string>) => void;
disabled?: boolean;
}
export const ContextVariablesSection = ({
initialContext,
setInitialContext,
disabled = false
}: ContextVariablesSectionProps) => {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
const handleAddContextVar = () => {
if (newKey && newValue && !initialContext[newKey]) {
setInitialContext({ ...initialContext, [newKey]: newValue });
setNewKey("");
setNewValue("");
}
};
const handleRemoveContextVar = (key: string) => {
const newVars = { ...initialContext };
delete newVars[key];
setInitialContext(newVars);
};
const handleUpdateContextVar = (key: string, value: string) => {
setInitialContext({ ...initialContext, [key]: value });
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Template Context Variables</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing Variables */}
{Object.entries(initialContext).length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium">Current Variables</Label>
{Object.entries(initialContext).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 p-3 border rounded-md bg-gray-50">
<div className="flex-1">
<Label className="text-xs text-gray-600">{key}</Label>
<Input
value={value}
onChange={(e) => handleUpdateContextVar(key, e.target.value)}
disabled={disabled}
className="mt-1"
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveContextVar(key)}
disabled={disabled}
>
<Trash2Icon className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</div>
)}
{/* Add New Variable */}
<div className="space-y-3">
<Label className="text-sm font-medium">Add New Variable</Label>
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="Variable key"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
disabled={disabled}
/>
</div>
<div className="flex-1">
<Input
placeholder="Variable value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
disabled={disabled}
/>
</div>
<Button
onClick={handleAddContextVar}
disabled={!newKey || !newValue || disabled || !!initialContext[newKey]}
>
Add
</Button>
</div>
{newKey && initialContext[newKey] && (
<p className="text-sm text-red-500">Variable with this key already exists</p>
)}
</div>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,34 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface WorkflowConfigErrorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
error: string | null;
onNavigateToWorkflow: () => void;
}
export const WorkflowConfigErrorDialog = ({
open,
onOpenChange,
error,
onNavigateToWorkflow
}: WorkflowConfigErrorProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Workflow Error</DialogTitle>
<DialogDescription className="text-red-500 whitespace-pre-line">
{error}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={onNavigateToWorkflow}>
Go to Workflow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,6 @@
export * from './ApiKeyErrorDialog';
export * from './AudioControls';
export * from './ConnectionStatus';
export * from './ContextDisplay';
export * from './ContextVariablesSection';
export * from './WorkflowConfigErrorDialog'

View file

@ -0,0 +1,2 @@
export * from './useDeviceInputs';
export * from './useWebRTC';

View file

@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import logger from '@/lib/logger';
export const useDeviceInputs = () => {
const [audioInputs, setAudioInputs] = useState<MediaDeviceInfo[]>([]);
const [selectedAudioInput, setSelectedAudioInput] = useState('');
const [permissionError, setPermissionError] = useState<string | null>(null);
useEffect(() => {
const getAudioInputs = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioDevices = devices.filter(device => device.kind === 'audioinput');
setAudioInputs(audioDevices);
const defaultAudioInput = audioDevices.find(device => device.deviceId === 'default');
if (defaultAudioInput) {
setSelectedAudioInput(defaultAudioInput.deviceId);
}
} catch (error) {
setPermissionError('Could not enumerate devices');
logger.error(`Error enumerating devices: ${error}`);
}
};
getAudioInputs();
}, []);
return {
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
permissionError,
setPermissionError
};
};

View file

@ -0,0 +1,275 @@
import { useRef, useState } from "react";
import { offerApiV1PipecatRtcOfferPost, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import logger from '@/lib/logger';
import { getRandomId } from "@/lib/utils";
import { sdpFilterCodec } from "../utils";
import { useDeviceInputs } from "./useDeviceInputs";
interface UseWebRTCProps {
workflowId: number;
workflowRunId: number;
accessToken: string | null;
initialContextVariables?: Record<string, string> | null;
}
export const useWebRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebRTCProps) => {
const [iceGatheringState, setIceGatheringState] = useState('');
const [iceConnectionState, setIceConnectionState] = useState('');
const [connectionActive, setConnectionActive] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [initialContext, setInitialContext] = useState<Record<string, string>>(
initialContextVariables || {}
);
const {
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
permissionError,
setPermissionError
} = useDeviceInputs();
const useStun = true;
const useAudio = true;
const audioCodec = 'default';
const audioRef = useRef<HTMLAudioElement>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const timeStartRef = useRef<number | null>(null);
const pc_id = 'PC-' + getRandomId().toString();
const createPeerConnection = () => {
const config: RTCConfiguration = {
iceServers: useStun ? [{ urls: ['stun:stun.l.google.com:19302'] }] : []
};
const pc = new RTCPeerConnection(config);
pc.addEventListener('icegatheringstatechange', () => {
logger.info(`ICE gathering state changed in createPeerConnection, ${pc.iceGatheringState}`);
setIceGatheringState(prevState => prevState + ' -> ' + pc.iceGatheringState);
});
setIceGatheringState(pc.iceGatheringState);
pc.addEventListener('iceconnectionstatechange', () => {
setIceConnectionState(prevState => prevState + ' -> ' + pc.iceConnectionState);
});
setIceConnectionState(pc.iceConnectionState);
pc.addEventListener('track', (evt) => {
if (evt.track.kind === 'audio' && audioRef.current) {
audioRef.current.srcObject = evt.streams[0];
}
});
pcRef.current = pc;
return pc;
};
const negotiate = async () => {
const pc = pcRef.current;
if (!pc) return;
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await new Promise<void>((resolve) => {
if (pc.iceGatheringState === 'complete') {
resolve();
} else {
const checkState = () => {
if (pc.iceGatheringState === 'complete') {
logger.debug(`ICE gathering is complete in negotiate, ${pc.iceGatheringState}`);
pc.removeEventListener('icegatheringstatechange', checkState);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', checkState);
}
});
const localDescription = pc.localDescription;
if (!localDescription) return;
let sdp = localDescription.sdp;
if (audioCodec !== 'default') {
sdp = sdpFilterCodec('audio', audioCodec, sdp);
}
if (!accessToken) return;
const response = await offerApiV1PipecatRtcOfferPost({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
body: {
sdp: sdp,
type: 'offer',
pc_id: pc_id,
restart_pc: false,
workflow_id: workflowId,
workflow_run_id: workflowRunId,
call_context_vars: initialContext
}
});
if (response && response.data) {
const answerSdpText = typeof response.data === 'object' && 'sdp' in response.data
? response.data.sdp as string
: '';
await pc.setRemoteDescription({
type: 'answer',
sdp: answerSdpText
});
setConnectionActive(true);
}
} catch (e) {
logger.error(`Negotiation failed: ${e}`);
}
};
const start = async () => {
if (isStarting || !accessToken) return;
setIsStarting(true);
try {
const response = await validateUserConfigurationsApiV1UserConfigurationsUserValidateGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
query: {
validity_ttl_seconds: 86400
},
});
if (response.error) {
setApiKeyModalOpen(true);
let msg = 'API Key Error';
const detail = (response.error as unknown as { detail?: { errors: { model: string; message: string }[] } }).detail;
if (Array.isArray(detail)) {
msg = detail
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
.join('\n');
}
setApiKeyError(msg);
return;
}
// Then check workflow validation
const workflowResponse = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
path: {
workflow_id: workflowId,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (workflowResponse.error) {
setWorkflowConfigModalOpen(true);
let msg = 'Workflow validation failed';
const errorDetail = workflowResponse.error as { detail?: { errors: WorkflowValidationError[] } };
if (errorDetail?.detail?.errors) {
msg = errorDetail.detail.errors
.map(err => `${err.kind}: ${err.message}`)
.join('\n');
}
setWorkflowConfigError(msg);
return;
}
timeStartRef.current = null;
const pc = createPeerConnection();
const constraints: MediaStreamConstraints = {
audio: false,
};
if (useAudio) {
const audioConstraints: MediaTrackConstraints = {};
if (selectedAudioInput) {
audioConstraints.deviceId = { exact: selectedAudioInput };
}
constraints.audio = Object.keys(audioConstraints).length ? audioConstraints : true;
}
if (constraints.audio) {
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
await negotiate();
} catch (err) {
logger.error(`Could not acquire media: ${err}`);
setPermissionError('Could not acquire media');
}
} else {
await negotiate();
}
} finally {
setIsStarting(false);
}
};
const stop = () => {
setConnectionActive(false);
setIsCompleted(true);
const pc = pcRef.current;
if (!pc) return;
if (pc.getTransceivers) {
pc.getTransceivers().forEach((transceiver) => {
if (transceiver.stop) {
transceiver.stop();
}
});
}
pc.getSenders().forEach((sender) => {
if (sender.track) {
sender.track.stop();
}
});
setTimeout(() => {
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
}, 500);
};
return {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
iceGatheringState,
iceConnectionState,
start,
stop,
isStarting,
initialContext,
setInitialContext
};
};

View file

@ -0,0 +1,217 @@
'use client';
import { ArrowLeft, FileText, Video } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import Pipecat from '@/app/workflow/[workflowId]/run/[runId]/Pipecat';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
import { MediaPreviewButtons, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/lib/auth';
import { downloadFile } from '@/lib/files';
import { ContextDisplay } from './components';
interface WorkflowRunResponse {
is_completed: boolean;
transcript_url: string | null;
recording_url: string | null;
initial_context: Record<string, string | number | boolean | object> | null;
gathered_context: Record<string, string | number | boolean | object> | null;
}
export default function WorkflowRunPage() {
const params = useParams();
const [isLoading, setIsLoading] = useState(true);
const auth = useAuth();
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
// Redirect if not authenticated
useEffect(() => {
if (!auth.loading && !auth.isAuthenticated) {
auth.redirectToLogin();
}
}, [auth]);
// Get access token
useEffect(() => {
if (auth.isAuthenticated && !auth.loading) {
auth.getAccessToken().then(setAccessToken);
}
}, [auth]);
const { openAudioModal, openTranscriptModal, dialog } = MediaPreviewDialog({ accessToken });
useEffect(() => {
const fetchWorkflowRun = async () => {
if (!auth.isAuthenticated || auth.loading) return;
setIsLoading(true);
const token = await auth.getAccessToken();
const workflowId = params.workflowId;
const runId = params.runId;
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
path: {
workflow_id: Number(workflowId),
run_id: Number(runId),
},
headers: {
'Authorization': `Bearer ${token}`,
},
});
setIsLoading(false);
setWorkflowRun({
is_completed: response.data?.is_completed ?? false,
transcript_url: response.data?.transcript_url ?? null,
recording_url: response.data?.recording_url ?? null,
initial_context: response.data?.initial_context as Record<string, string> | null ?? null,
gathered_context: response.data?.gathered_context as Record<string, string> | null ?? null,
});
};
fetchWorkflowRun();
}, [params.workflowId, params.runId, auth]);
const backButton = (
<div className="flex gap-2">
<Link href={`/workflow/${params.workflowId}`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow
</Button>
</Link>
<Link href={`/workflow/${params.workflowId}/runs`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow Runs
</Button>
</Link>
</div>
);
let returnValue = null;
if (isLoading) {
returnValue = (
<div className="min-h-screen flex mt-40 justify-center">
<div className="w-full max-w-4xl p-6">
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
<CardFooter className="flex gap-4">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
</CardFooter>
</Card>
</div>
</div>
);
}
else if (workflowRun?.is_completed) {
returnValue = (
<div className="min-h-screen flex mt-40 justify-center p-6">
<div className="w-full max-w-4xl space-y-6">
<Card className="border-gray-100">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-2xl">Workflow Run Completed</CardTitle>
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
</div>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-8">Your workflow run has been completed successfully. You can preview or download the transcript and recording.</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Preview:</span>
<MediaPreviewButtons
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenAudio={openAudioModal}
onOpenTranscript={openTranscriptModal}
/>
</div>
<div className="flex items-center gap-2 border-l pl-4">
<span className="text-sm text-gray-600">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url, accessToken!)}
disabled={!workflowRun?.transcript_url || !accessToken}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url, accessToken!)}
disabled={!workflowRun?.recording_url || !accessToken}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
context={workflowRun?.initial_context}
/>
<ContextDisplay
title="Gathered Context"
context={workflowRun?.gathered_context}
/>
</div>
</div>
</div>
);
}
else {
returnValue =
<div className="min-h-screen mt-40">
<Pipecat
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}
accessToken={accessToken}
initialContextVariables={
workflowRun?.initial_context
? Object.fromEntries(
Object.entries(workflowRun.initial_context).map(([key, value]) => [
key,
typeof value === 'object' && value !== null
? JSON.stringify(value)
: String(value)
])
)
: null
}
/>
</div>
}
return (
<WorkflowLayout backButton={backButton}>
{returnValue}
{dialog}
</WorkflowLayout>
);
}

View file

@ -0,0 +1 @@
export * from './webrtcUtils';

View file

@ -0,0 +1,66 @@
/**
* Escapes special characters in a string for use in a regular expression.
*/
export const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
/**
* Filters codecs in an SDP string.
*/
export const sdpFilterCodec = (kind: string, codec: string, realSdp: string): string => {
const allowed: number[] = [];
const rtxRegex = new RegExp('a=fmtp:(\\d+) apt=(\\d+)\r$');
const codecRegex = new RegExp('a=rtpmap:([0-9]+) ' + escapeRegExp(codec));
const videoRegex = new RegExp('(m=' + kind + ' .*?)( ([0-9]+))*\\s*$');
const lines = realSdp.split('\n');
let isKind = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('m=' + kind + ' ')) {
isKind = true;
} else if (lines[i].startsWith('m=')) {
isKind = false;
}
if (isKind) {
let match = lines[i].match(codecRegex);
if (match) {
allowed.push(parseInt(match[1]));
}
match = lines[i].match(rtxRegex);
if (match && allowed.includes(parseInt(match[2]))) {
allowed.push(parseInt(match[1]));
}
}
}
const skipRegex = 'a=(fmtp|rtcp-fb|rtpmap):([0-9]+)';
let sdp = '';
isKind = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('m=' + kind + ' ')) {
isKind = true;
} else if (lines[i].startsWith('m=')) {
isKind = false;
}
if (isKind) {
const skipMatch = lines[i].match(skipRegex);
if (skipMatch && !allowed.includes(parseInt(skipMatch[2]))) {
continue;
} else if (lines[i].match(videoRegex)) {
sdp += lines[i].replace(videoRegex, '$1 ' + allowed.join(' ')) + '\n';
} else {
sdp += lines[i] + '\n';
}
} else {
sdp += lines[i] + '\n';
}
}
return sdp;
};

View file

@ -0,0 +1,359 @@
"use client";
import { ArrowLeft, ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet,getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
import { WorkflowRunResponseSchema } from "@/client/types.gen";
import { FilterBuilder } from "@/components/filters/FilterBuilder";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
import { useUserConfig } from '@/context/UserConfigContext';
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
import { downloadFile } from "@/lib/files";
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
import WorkflowLayout from '../../WorkflowLayout';
export default function WorkflowRunsPage() {
const { workflowId } = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRunResponseSchema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(() => {
const pageParam = searchParams.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
});
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
const { accessToken } = useUserConfig();
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
return decodeFiltersFromURL(searchParams, availableAttributes);
});
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
// Load disposition codes from workflow configuration
useEffect(() => {
const loadDispositionCodes = async () => {
if (!accessToken) return;
try {
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
path: { workflow_id: Number(workflowId) },
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const workflow = response.data;
if (workflow?.call_disposition_codes) {
// Update the disposition code attribute with actual options
const updatedAttributes = configuredAttributes.map(attr => {
if (attr.id === 'dispositionCode') {
return {
...attr,
config: {
...attr.config,
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
? Object.keys(workflow.call_disposition_codes || {})
: [...DISPOSITION_CODES]
}
};
}
return attr;
});
setConfiguredAttributes(updatedAttributes);
}
} catch (err) {
console.error("Failed to load disposition codes:", err);
}
};
loadDispositionCodes();
}, [workflowId, accessToken, configuredAttributes]);
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!accessToken) return;
try {
setLoading(true);
// Prepare filter data for API
let filterParam = undefined;
if (filters && filters.length > 0) {
const filterData = filters.map(filter => ({
attribute: filter.attribute.id,
type: filter.attribute.type,
value: filter.value
}));
filterParam = JSON.stringify(filterData);
}
const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({
path: { workflow_id: Number(workflowId) },
query: {
page: page,
limit: 50,
...(filterParam && { filters: filterParam })
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.error) {
throw new Error("Failed to fetch workflow runs");
}
if (response.data) {
setWorkflowRuns(response.data.runs || []);
setTotalPages(response.data.total_pages || 1);
setTotalCount(response.data.total_count || 0);
setCurrentPage(response.data.page || 1);
}
setError(null);
} catch (err) {
console.error("Error fetching workflow runs:", err);
setError("Failed to load workflow runs");
} finally {
setLoading(false);
}
}, [workflowId, accessToken]);
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
const params = new URLSearchParams();
params.set('page', page.toString());
// Add filters to URL if present
if (filters && filters.length > 0) {
const filterString = encodeFiltersToURL(filters);
if (filterString) {
const filterParams = new URLSearchParams(filterString);
filterParams.forEach((value, key) => params.set(key, value));
}
}
router.push(`/workflow/${workflowId}/runs?${params.toString()}`);
}, [router, workflowId]);
useEffect(() => {
fetchWorkflowRuns(currentPage, activeFilters);
}, [currentPage, activeFilters, fetchWorkflowRuns]);
const handleApplyFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1); // Reset to first page when applying filters
updatePageInUrl(1, activeFilters);
await fetchWorkflowRuns(1, activeFilters);
setIsExecutingFilters(false);
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl]);
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
setActiveFilters(filters);
}, []);
const handleClearFilters = useCallback(async () => {
setIsExecutingFilters(true);
setCurrentPage(1);
updatePageInUrl(1, []); // Clear filters from URL
await fetchWorkflowRuns(1, []); // Fetch all workflows without filters
setIsExecutingFilters(false);
}, [fetchWorkflowRuns, updatePageInUrl]);
const backButton = (
<Link href={`/workflow/${workflowId}`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow
</Button>
</Link>
);
return (
<WorkflowLayout backButton={backButton}>
<div className="container mx-auto py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
<FilterBuilder
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
) : workflowRuns.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">No workflow runs found</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Showing {workflowRuns.length} of {totalCount} total runs
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflowRuns.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer"
onClick={() => router.push(`/workflow/${workflowId}/run/${run.id}`)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.dograh_token_usage === 'number'
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
: "-"}
</TableCell>
<TableCell>
<div className="flex space-x-2">
{run.transcript_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.transcript_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Transcript
</Button>
)}
{run.recording_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.recording_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Recording
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
router.push(`/workflow/${workflowId}/run/${run.id}`);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
View
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage - 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage + 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
</WorkflowLayout>
);
}

View file

@ -0,0 +1,215 @@
import { Settings } from 'lucide-react';
import Link from 'next/link';
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet, getWorkflowTemplatesApiV1WorkflowTemplatesGet } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { DuplicateWorkflowTemplate } from "@/components/workflow/TemplateCard";
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
import logger from '@/lib/logger';
import WorkflowLayout from "./WorkflowLayout";
// Server component for workflow templates
async function WorkflowTemplatesList() {
try {
const response = await getWorkflowTemplatesApiV1WorkflowTemplatesGet();
// Log request URL if available
if (response.request?.url) {
logger.info(`Template Request URL: ${response.request.url}`);
}
const templates = response.data || [];
// Get access token on server side to pass to client component
const accessToken = await getServerAccessToken();
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{templates.map((template) => (
<DuplicateWorkflowTemplate
key={template.id}
id={template.id}
title={template.template_name}
description={template.template_description}
serverAccessToken={accessToken}
/>
))}
</div>
);
} catch (err) {
logger.error(`Error fetching workflow templates: ${err}`);
return (
<div className="text-red-500">
Failed to load Workflow Templates. Please Try Again Later.
</div>
);
}
}
// Server component for workflow list
async function WorkflowList() {
const authProvider = getServerAuthProvider();
const accessToken = await getServerAccessToken();
logger.debug(`In WorkflowList, authProvider: ${authProvider}, accessToken: ${accessToken}`);
if (!accessToken) {
// If no token, user needs to sign in
const { redirect } = await import('next/navigation');
if (authProvider === 'stack') {
redirect('/');
} else {
// For OSS mode, this shouldn't happen as token is auto-generated
return (
<div className="text-red-500">
Authentication required. Please refresh the page.
</div>
);
}
}
try {
// Fetch both active and archived workflows in a single request
const response = await getWorkflowsApiV1WorkflowFetchGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
query: {
status: 'active,archived'
}
});
const allWorkflowData = response.data ? (Array.isArray(response.data) ? response.data : [response.data]) : [];
// Separate active and archived workflows
const activeWorkflows = allWorkflowData
.filter(w => w.status === 'active')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const archivedWorkflows = allWorkflowData
.filter(w => w.status === 'archived')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
return (
<>
{/* Active Workflows Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Active Workflows</h2>
{activeWorkflows.length > 0 ? (
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
) : (
<div className="text-gray-500 bg-gray-50 rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
</div>
)}
</div>
{/* Archived Workflows Section */}
{archivedWorkflows.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 text-gray-600">Archived Workflows</h2>
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
</div>
)}
</>
);
} catch (err) {
logger.error(`Error fetching workflows: ${err}`);
return (
<div className="text-red-500">
Failed to load Workflows. Please Try Again Later.
</div>
);
}
}
async function PageContent() {
const workflowList = await WorkflowList();
return (
<div className="container mx-auto px-4 py-8">
{/* Get Started Section */}
<div className="mb-12">
<div className="flex justify-between items-center px-4">
<h2 className="text-2xl font-bold mb-6">Get Started</h2>
<div className="flex gap-2">
<Link href="/service-configurations">
<Button className="flex items-center gap-2 mb-6">
<Settings size={16} />
Configure Services
</Button>
</Link>
<Link href="/integrations">
<Button className="flex items-center gap-2 mb-6">
<Settings size={16} />
Integrations
</Button>
</Link>
</div>
</div>
<Suspense fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
))}
</div>
}>
<WorkflowTemplatesList />
</Suspense>
</div>
{/* Your Workflows Section */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Your Workflows</h1>
<div className="flex gap-2">
<UploadWorkflowButton />
<CreateWorkflowButton />
</div>
</div>
{workflowList}
</div>
</div>
);
}
function WorkflowsLoading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Get Started Section Loading */}
<div className="mb-12">
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
))}
</div>
</div>
{/* Your Workflows Section Loading */}
<div className="mb-6">
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded"></div>
<div className="h-10 w-32 bg-gray-200 rounded"></div>
</div>
<div className="bg-gray-200 rounded-lg h-96"></div>
</div>
</div>
);
}
export default function WorkflowPage() {
return (
<WorkflowLayout showFeaturesNav={true}>
<Suspense fallback={<WorkflowsLoading />}>
<PageContent />
</Suspense>
</WorkflowLayout>
);
}