mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
Initial Commit 🚀 🚀
This commit is contained in:
commit
4f2a629340
444 changed files with 76863 additions and 0 deletions
21
ui/src/app/workflow/WorkflowLayout.tsx
Normal file
21
ui/src/app/workflow/WorkflowLayout.tsx
Normal 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
|
||||
149
ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx
Normal file
149
ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
178
ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx
Normal file
178
ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx
Normal 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;
|
||||
321
ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx
Normal file
321
ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx
Normal 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;
|
||||
2
ui/src/app/workflow/[workflowId]/components/index.ts
Normal file
2
ui/src/app/workflow/[workflowId]/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './WorkflowControls';
|
||||
export * from './WorkflowHeader';
|
||||
|
|
@ -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;
|
||||
};
|
||||
1
ui/src/app/workflow/[workflowId]/hooks/index.ts
Normal file
1
ui/src/app/workflow/[workflowId]/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './useWorkflowState';
|
||||
446
ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts
Normal file
446
ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts
Normal 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
|
||||
};
|
||||
};
|
||||
91
ui/src/app/workflow/[workflowId]/page.tsx
Normal file
91
ui/src/app/workflow/[workflowId]/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
114
ui/src/app/workflow/[workflowId]/run/[runId]/Pipecat.tsx
Normal file
114
ui/src/app/workflow/[workflowId]/run/[runId]/Pipecat.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './ApiKeyErrorDialog';
|
||||
export * from './AudioControls';
|
||||
export * from './ConnectionStatus';
|
||||
export * from './ContextDisplay';
|
||||
export * from './ContextVariablesSection';
|
||||
export * from './WorkflowConfigErrorDialog'
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './useDeviceInputs';
|
||||
export * from './useWebRTC';
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
275
ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebRTC.tsx
Normal file
275
ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebRTC.tsx
Normal 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
|
||||
};
|
||||
};
|
||||
217
ui/src/app/workflow/[workflowId]/run/[runId]/page.tsx
Normal file
217
ui/src/app/workflow/[workflowId]/run/[runId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './webrtcUtils';
|
||||
|
|
@ -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;
|
||||
};
|
||||
359
ui/src/app/workflow/[workflowId]/runs/page.tsx
Normal file
359
ui/src/app/workflow/[workflowId]/runs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
ui/src/app/workflow/page.tsx
Normal file
215
ui/src/app/workflow/page.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue