feat: allow uploading recording as part of node transition

This commit is contained in:
Abhishek Kumar 2026-04-10 11:54:00 +05:30
parent bb5f56bfb7
commit 65c76ca7ff
36 changed files with 2255 additions and 201 deletions

View file

@ -3,25 +3,19 @@
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { getPresignedUploadUrlApiV1S3PresignedUploadUrlPost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import logger from '@/lib/logger';
interface CsvUploadSelectorProps {
accessToken: string;
onFileUploaded: (fileKey: string, fileName: string) => void;
selectedFileName?: string;
}
interface PresignedUploadUrlResponse {
upload_url: string;
file_key: string;
expires_in: number;
}
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export default function CsvUploadSelector({ accessToken, onFileUploaded, selectedFileName }: CsvUploadSelectorProps) {
export default function CsvUploadSelector({ onFileUploaded, selectedFileName }: CsvUploadSelectorProps) {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -48,25 +42,18 @@ export default function CsvUploadSelector({ accessToken, onFileUploaded, selecte
try {
// Step 1: Request presigned upload URL
logger.info('Requesting presigned upload URL for:', file.name);
const presignedResponse = await fetch('/api/v1/s3/presigned-upload-url', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
const { data: presignedData, error } = await getPresignedUploadUrlApiV1S3PresignedUploadUrlPost({
body: {
file_name: file.name,
file_size: file.size,
content_type: 'text/csv',
}),
},
});
if (!presignedResponse.ok) {
const error = await presignedResponse.json();
throw new Error(error.detail || 'Failed to get upload URL');
if (error || !presignedData) {
throw new Error('Failed to get upload URL');
}
const presignedData: PresignedUploadUrlResponse = await presignedResponse.json();
logger.info('Received presigned URL, uploading file...');
// Step 2: Upload file directly to S3/MinIO

View file

@ -415,7 +415,6 @@ export default function NewCampaignPage() {
/>
) : (
<CsvUploadSelector
accessToken={userAccessToken}
onFileUploaded={handleFileUploaded}
selectedFileName={selectedFileName}
/>

View file

@ -0,0 +1,382 @@
"use client";
import { AudioLines, Check, Pause, Pencil, Play, RefreshCw, Search, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete,
getWorkflowsSummaryApiV1WorkflowSummaryGet,
listRecordingsApiV1WorkflowRecordingsGet,
updateRecordingApiV1WorkflowRecordingsIdPatch,
} from "@/client/sdk.gen";
import type { RecordingResponseSchema, WorkflowSummaryResponse } from "@/client/types.gen";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import logger from "@/lib/logger";
const ALL_VALUE = "__all__";
export default function RecordingsList() {
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [error, setError] = useState<string | null>(null);
// Filters
const [selectedWorkflow, setSelectedWorkflow] = useState<string>(ALL_VALUE);
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const { playingId, toggle: togglePlayback, stop: stopPlayback } = useAudioPlayback();
const hasFetchedWorkflows = useRef(false);
const workflowMap = useMemo(() => {
const map = new Map<number, string>();
for (const w of workflows) {
map.set(w.id, w.name);
}
return map;
}, [workflows]);
const fetchWorkflows = useCallback(async () => {
try {
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet();
if (response.data) {
setWorkflows(response.data);
}
} catch (err) {
logger.error("Error fetching workflows:", err);
}
}, []);
const fetchRecordings = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await listRecordingsApiV1WorkflowRecordingsGet({
query: {
workflow_id: selectedWorkflow !== ALL_VALUE ? Number(selectedWorkflow) : undefined,
},
});
if (response.error || !response.data) {
throw new Error("Failed to fetch recordings");
}
setRecordings(response.data.recordings);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch recordings");
logger.error("Error fetching recordings:", err);
} finally {
setIsLoading(false);
}
}, [selectedWorkflow]);
useEffect(() => {
if (!hasFetchedWorkflows.current) {
hasFetchedWorkflows.current = true;
fetchWorkflows();
}
}, [fetchWorkflows]);
useEffect(() => {
fetchRecordings();
}, [fetchRecordings]);
const handleDelete = async (recordingId: string) => {
if (!confirm("Are you sure you want to delete this recording?")) return;
try {
const response = await deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete({
path: { recording_id: recordingId },
});
if (response.error) {
throw new Error("Failed to delete recording");
}
toast.success("Recording deleted");
fetchRecordings();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete recording");
logger.error("Error deleting recording:", err);
}
};
const handlePlay = async (rec: RecordingResponseSchema) => {
try {
await togglePlayback(rec.recording_id, rec.storage_key, rec.storage_backend);
} catch {
toast.error("Failed to play recording");
}
};
const startEditing = (rec: RecordingResponseSchema) => {
setEditingId(rec.recording_id);
setEditValue(rec.recording_id);
};
const cancelEditing = () => {
setEditingId(null);
setEditValue("");
};
const saveRecordingId = async (rec: RecordingResponseSchema) => {
const newId = editValue.trim();
if (!newId) {
toast.error("Recording ID cannot be empty");
return;
}
if (newId === rec.recording_id) {
cancelEditing();
return;
}
try {
const response = await updateRecordingApiV1WorkflowRecordingsIdPatch({
path: { id: rec.id },
body: { recording_id: newId },
});
if (response.error) {
const errData = response.error as { detail?: string };
throw new Error(errData?.detail || "Failed to update recording ID");
}
toast.success(`Recording ID updated to "${newId}"`);
cancelEditing();
fetchRecordings();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update recording ID");
}
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
};
const filteredRecordings = recordings.filter((rec) => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
const filename = (rec.metadata?.original_filename as string) || "";
return (
filename.toLowerCase().includes(q) ||
rec.transcript.toLowerCase().includes(q) ||
rec.recording_id.toLowerCase().includes(q)
);
});
if (isLoading && recordings.length === 0) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-64" />
</div>
<Skeleton className="h-8 w-24" />
</div>
))}
</div>
);
}
if (error) {
return (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
{error}
</div>
);
}
return (
<div className="space-y-4">
{/* Filter */}
<div className="max-w-xs">
<label className="text-xs text-muted-foreground mb-1 block">Voice Agent</label>
<Select value={selectedWorkflow} onValueChange={setSelectedWorkflow}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="All agents" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>All agents</SelectItem>
{workflows.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Search and Refresh */}
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by filename, transcript, or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button
variant="outline"
size="icon"
onClick={() => { stopPlayback(); fetchRecordings(); }}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* Results count */}
<div className="text-sm text-muted-foreground">
{filteredRecordings.length} recording{filteredRecordings.length !== 1 ? "s" : ""}
{searchQuery && ` matching "${searchQuery}"`}
</div>
{/* Recordings List */}
{filteredRecordings.length === 0 ? (
<div className="text-center py-12">
<AudioLines className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">
{searchQuery
? "No recordings match your search"
: "No recordings found for the selected filters"}
</p>
</div>
) : (
<div className="space-y-3">
{filteredRecordings.map((rec) => {
const filename = (rec.metadata?.original_filename as string) || "";
const workflowName = workflowMap.get(rec.workflow_id);
const isEditing = editingId === rec.recording_id;
return (
<div
key={rec.recording_id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<AudioLines className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
{/* Recording ID (editable) */}
<div className="flex items-center gap-2 mb-1">
{isEditing ? (
<div className="flex items-center gap-1">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveRecordingId(rec);
if (e.key === "Escape") cancelEditing();
}}
className="h-7 text-sm font-mono w-48"
maxLength={64}
autoFocus
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => saveRecordingId(rec)}
>
<Check className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={cancelEditing}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<div className="flex items-center gap-1.5 group">
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded truncate max-w-[250px]">
{rec.recording_id}
</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => startEditing(rec)}
>
<Pencil className="w-3 h-3" />
</Button>
</div>
)}
{workflowName && (
<Badge variant="outline" className="text-xs shrink-0">
{workflowName}
</Badge>
)}
</div>
{/* Filename */}
{filename && (
<p className="text-xs text-muted-foreground mb-0.5 truncate max-w-[300px]">
{filename}
</p>
)}
{/* Transcript */}
<p className="text-sm text-muted-foreground line-clamp-1 mb-1">
{rec.transcript}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<span>{rec.tts_provider}</span>
<span>{rec.tts_model}</span>
<span className="truncate max-w-[150px]">{rec.tts_voice_id}</span>
<span>{formatDate(rec.created_at)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0 ml-2">
<Button
variant="ghost"
size="sm"
onClick={() => handlePlay(rec)}
>
{playingId === rec.recording_id ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(rec.recording_id)}
className="text-destructive hover:text-destructive/90"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/lib/auth";
import RecordingsList from "./RecordingsList";
export default function RecordingsPage() {
const { user, redirectToLogin, loading } = useAuth();
useEffect(() => {
if (!loading && !user) {
redirectToLogin();
}
}, [loading, user, redirectToLogin]);
if (loading || !user) {
return (
<div className="container mx-auto px-4 py-8">
<div className="space-y-4">
<Skeleton className="h-12 w-64" />
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Recordings</h1>
<p className="text-muted-foreground">
View all audio recordings across your voice agents. Filter by agent, provider, model, or voice.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>All Recordings</CardTitle>
<CardDescription>
Audio recordings scoped to your organization
</CardDescription>
</CardHeader>
<CardContent>
<RecordingsList />
</CardContent>
</Card>
</div>
);
}

View file

@ -2,6 +2,8 @@
import { AlertCircle } from "lucide-react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { RecordingSelect } from "@/components/flow/TextOrAudioInput";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -20,6 +22,9 @@ export interface EndCallToolConfigProps {
onMessageTypeChange: (messageType: EndCallMessageType) => void;
customMessage: string;
onCustomMessageChange: (message: string) => void;
audioRecordingId: string;
onAudioRecordingIdChange: (id: string) => void;
recordings?: RecordingResponseSchema[];
endCallReason: boolean;
onEndCallReasonChange: (enabled: boolean) => void;
endCallReasonDescription: string;
@ -35,6 +40,9 @@ export function EndCallToolConfig({
onMessageTypeChange,
customMessage,
onCustomMessageChange,
audioRecordingId,
onAudioRecordingIdChange,
recordings = [],
endCallReason,
onEndCallReasonChange,
endCallReasonDescription,
@ -148,6 +156,24 @@ export function EndCallToolConfig({
/>
</div>
)}
<div className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50">
<RadioGroupItem value="audio" id="audio" className="mt-1" />
<label htmlFor="audio" className="flex-1 space-y-2 cursor-pointer">
<span className="font-medium">Pre-recorded Audio</span>
<p className="text-xs text-muted-foreground">
Play a pre-recorded audio file before disconnecting
</p>
</label>
</div>
{messageType === "audio" && (
<div className="pl-8">
<RecordingSelect
value={audioRecordingId}
onChange={onAudioRecordingIdChange}
recordings={recordings}
/>
</div>
)}
</RadioGroup>
</div>
</CardContent>

View file

@ -2,6 +2,8 @@
import { AlertCircle } from "lucide-react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import {
CredentialSelector,
type HttpMethod,
@ -37,6 +39,11 @@ export interface HttpApiToolConfigProps {
onTimeoutMsChange: (timeout: number) => void;
customMessage: string;
onCustomMessageChange: (message: string) => void;
customMessageType: 'text' | 'audio';
onCustomMessageTypeChange: (type: 'text' | 'audio') => void;
customMessageRecordingId: string;
onCustomMessageRecordingIdChange: (id: string) => void;
recordings?: RecordingResponseSchema[];
}
export function HttpApiToolConfig({
@ -58,6 +65,11 @@ export function HttpApiToolConfig({
onTimeoutMsChange,
customMessage,
onCustomMessageChange,
customMessageType,
onCustomMessageTypeChange,
customMessageRecordingId,
onCustomMessageRecordingIdChange,
recordings = [],
}: HttpApiToolConfigProps) {
return (
<Card>
@ -136,18 +148,28 @@ export function HttpApiToolConfig({
<div className="grid gap-2 pt-4 border-t">
<Label>Custom Message</Label>
<Label className="text-xs text-muted-foreground">
Optional message the AI will speak before executing this tool (e.g., &quot;Let me look that up for you&quot;)
Optional message the AI will speak or play before executing this tool.
</Label>
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
</div>
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}
placeholder="e.g., Let me check that for you, one moment please."
rows={2}
/>
<TextOrAudioInput
type={customMessageType}
onTypeChange={onCustomMessageTypeChange}
recordingId={customMessageRecordingId}
onRecordingIdChange={onCustomMessageRecordingIdChange}
recordings={recordings}
>
<>
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
</div>
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}
placeholder="e.g., Let me check that for you, one moment please."
rows={2}
/>
</>
</TextOrAudioInput>
</div>
</TabsContent>

View file

@ -3,6 +3,8 @@
import { AlertCircle } from "lucide-react";
import {useState } from "react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { RecordingSelect } from "@/components/flow/TextOrAudioInput";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -22,6 +24,9 @@ export interface TransferCallToolConfigProps {
onMessageTypeChange: (messageType: EndCallMessageType) => void;
customMessage: string;
onCustomMessageChange: (message: string) => void;
audioRecordingId: string;
onAudioRecordingIdChange: (id: string) => void;
recordings?: RecordingResponseSchema[];
timeout?: number; // Make optional to match API type
onTimeoutChange: (timeout: number) => void;
}
@ -37,6 +42,9 @@ export function TransferCallToolConfig({
onMessageTypeChange,
customMessage,
onCustomMessageChange,
audioRecordingId,
onAudioRecordingIdChange,
recordings = [],
timeout,
onTimeoutChange,
}: TransferCallToolConfigProps) {
@ -181,6 +189,24 @@ export function TransferCallToolConfig({
/>
</div>
)}
<div className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50">
<RadioGroupItem value="audio" id="audio" className="mt-1" />
<label htmlFor="audio" className="flex-1 space-y-2 cursor-pointer">
<span className="font-medium">Pre-recorded Audio</span>
<p className="text-xs text-muted-foreground">
Play a pre-recorded audio file before transferring
</p>
</label>
</div>
{messageType === "audio" && (
<div className="pl-8">
<RecordingSelect
value={audioRecordingId}
onChange={onAudioRecordingIdChange}
recordings={recordings}
/>
</div>
)}
</RadioGroup>
</div>

View file

@ -75,6 +75,7 @@ export default function ToolDetailPage() {
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
const [endCallReason, setEndCallReason] = useState(false);
const [endCallReasonDescription, setEndCallReasonDescription] = useState("");
const [audioRecordingId, setAudioRecordingId] = useState("");
const handleEndCallReasonChange = (enabled: boolean) => {
setEndCallReason(enabled);
@ -87,6 +88,11 @@ export default function ToolDetailPage() {
const [transferDestination, setTransferDestination] = useState("");
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
const [transferTimeout, setTransferTimeout] = useState(30);
const [transferAudioRecordingId, setTransferAudioRecordingId] = useState("");
// HTTP API form state - custom message type
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
const [customMessageRecordingId, setCustomMessageRecordingId] = useState("");
// Redirect if not authenticated
useEffect(() => {
@ -132,11 +138,14 @@ export default function ToolDetailPage() {
if (config) {
setEndCallMessageType(config.messageType || "none");
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setAudioRecordingId((config as any).audioRecordingId || "");
setEndCallReason(config.endCallReason ?? false);
setEndCallReasonDescription(config.endCallReasonDescription || "");
} else {
setEndCallMessageType("none");
setCustomMessage("");
setAudioRecordingId("");
setEndCallReason(false);
setEndCallReasonDescription("");
}
@ -147,11 +156,14 @@ export default function ToolDetailPage() {
setTransferDestination(config.destination || "");
setTransferMessageType(config.messageType || "none");
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTransferAudioRecordingId((config as any).audioRecordingId || "");
setTransferTimeout(config.timeout ?? 30);
} else {
setTransferDestination("");
setTransferMessageType("none");
setCustomMessage("");
setTransferAudioRecordingId("");
setTransferTimeout(30);
}
} else {
@ -163,6 +175,10 @@ export default function ToolDetailPage() {
setCredentialUuid(config.credential_uuid || "");
setTimeoutMs(config.timeout_ms || 5000);
setCustomMessage(config.customMessage || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setCustomMessageType((config as any).customMessageType || "text");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setCustomMessageRecordingId((config as any).customMessageRecordingId || "");
// Convert headers object to array
if (config.headers) {
@ -259,6 +275,7 @@ export default function ToolDetailPage() {
config: {
messageType: endCallMessageType,
customMessage: endCallMessageType === "custom" ? customMessage : undefined,
audioRecordingId: endCallMessageType === "audio" ? audioRecordingId || undefined : undefined,
endCallReason,
endCallReasonDescription: endCallReason ? endCallReasonDescription || undefined : undefined,
},
@ -276,6 +293,7 @@ export default function ToolDetailPage() {
destination: transferDestination,
messageType: transferMessageType,
customMessage: transferMessageType === "custom" ? customMessage : undefined,
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
timeout: transferTimeout,
},
},
@ -306,7 +324,9 @@ export default function ToolDetailPage() {
parameters:
validParameters.length > 0 ? validParameters : undefined,
timeout_ms: timeoutMs,
customMessage: customMessage || undefined,
customMessage: customMessageType === 'text' ? (customMessage || undefined) : undefined,
customMessageType,
customMessageRecordingId: customMessageType === 'audio' ? (customMessageRecordingId || undefined) : undefined,
},
},
};
@ -490,6 +510,8 @@ const data = await response.json();`;
onMessageTypeChange={setEndCallMessageType}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
audioRecordingId={audioRecordingId}
onAudioRecordingIdChange={setAudioRecordingId}
endCallReason={endCallReason}
onEndCallReasonChange={handleEndCallReasonChange}
endCallReasonDescription={endCallReasonDescription}
@ -507,6 +529,8 @@ const data = await response.json();`;
onMessageTypeChange={setTransferMessageType}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
audioRecordingId={transferAudioRecordingId}
onAudioRecordingIdChange={setTransferAudioRecordingId}
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
/>
@ -530,6 +554,10 @@ const data = await response.json();`;
onTimeoutMsChange={setTimeoutMs}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
customMessageType={customMessageType}
onCustomMessageTypeChange={setCustomMessageType}
customMessageRecordingId={customMessageRecordingId}
onCustomMessageRecordingIdChange={setCustomMessageRecordingId}
/>
)}

View file

@ -14,7 +14,7 @@ import type {
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration";
export type EndCallMessageType = "none" | "custom";
export type EndCallMessageType = "none" | "custom" | "audio";
export interface ToolCategoryConfig {
value: ToolCategory;

View file

@ -19,6 +19,7 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
@ -113,6 +114,21 @@ function GeneralSection({
const ambientFileInputRef = useRef<HTMLInputElement>(null);
const { playingId, toggle: togglePlayback } = useAudioPlayback();
const isDirty = useMemo(() => {
const initAmbient = workflowConfigurations.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG;
return (
name !== workflowName ||
JSON.stringify(ambientNoiseConfig) !== JSON.stringify(initAmbient) ||
maxCallDuration !== (workflowConfigurations.max_call_duration || 600) ||
maxUserIdleTimeout !== (workflowConfigurations.max_user_idle_timeout || 10) ||
smartTurnStopSecs !== (workflowConfigurations.smart_turn_stop_secs || 2) ||
turnStopStrategy !== (workflowConfigurations.turn_stop_strategy || "transcription") ||
contextCompactionEnabled !== (workflowConfigurations.context_compaction_enabled ?? false)
);
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStopStrategy, contextCompactionEnabled, workflowConfigurations]);
useUnsavedChanges("general", isDirty);
const handleAmbientFileUpload = async (file: File) => {
if (file.size > MAX_AMBIENT_NOISE_FILE_SIZE) {
setAudioUploadError(`File too large (${(file.size / (1024 * 1024)).toFixed(1)}MB). Maximum is 10MB.`);
@ -463,8 +479,9 @@ function GeneralSection({
</div>
</div>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
<CardFooter className="justify-end gap-3 border-t pt-6">
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
{isSaving ? "Saving..." : "Save General Settings"}
</Button>
</CardFooter>
@ -488,6 +505,13 @@ function TemplateVariablesSection({
const [newValue, setNewValue] = useState("");
const [isSaving, setIsSaving] = useState(false);
const isDirty = useMemo(() => {
const pendingVars = newKey && newValue ? { ...contextVars, [newKey]: newValue } : contextVars;
return JSON.stringify(pendingVars) !== JSON.stringify(templateContextVariables);
}, [contextVars, newKey, newValue, templateContextVariables]);
useUnsavedChanges("variables", isDirty);
const handleAdd = () => {
if (newKey && newValue) {
setContextVars((prev) => ({ ...prev, [newKey]: newValue }));
@ -578,8 +602,9 @@ function TemplateVariablesSection({
</Button>
</div>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
<CardFooter className="justify-end gap-3 border-t pt-6">
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
{isSaving ? "Saving..." : "Save Variables"}
</Button>
</CardFooter>
@ -601,6 +626,10 @@ function DictionarySection({
const [dictionaryValue, setDictionaryValue] = useState(dictionary);
const [isSaving, setIsSaving] = useState(false);
const isDirty = dictionaryValue !== dictionary;
useUnsavedChanges("dictionary", isDirty);
const handleSave = async () => {
setIsSaving(true);
try {
@ -633,8 +662,9 @@ function DictionarySection({
className="resize-none"
/>
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
<CardFooter className="justify-end gap-3 border-t pt-6">
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
{isSaving ? "Saving..." : "Save Dictionary"}
</Button>
</CardFooter>
@ -669,6 +699,24 @@ function VoicemailSection({
const [longSpeechTimeout, setLongSpeechTimeout] = useState(getConfig().long_speech_timeout);
const [isSaving, setIsSaving] = useState(false);
const isDirty = useMemo(() => {
const init = {
...DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
...workflowConfigurations.voicemail_detection,
};
return (
enabled !== init.enabled ||
useWorkflowLlm !== init.use_workflow_llm ||
provider !== (init.provider || "openai") ||
model !== (init.model || "gpt-4.1") ||
apiKey !== (init.api_key || "") ||
systemPrompt !== (init.system_prompt || DEFAULT_VOICEMAIL_SYSTEM_PROMPT) ||
longSpeechTimeout !== init.long_speech_timeout
);
}, [enabled, useWorkflowLlm, provider, model, apiKey, systemPrompt, longSpeechTimeout, workflowConfigurations]);
useUnsavedChanges("voicemail", isDirty);
const handleSave = async () => {
setIsSaving(true);
try {
@ -772,8 +820,9 @@ function VoicemailSection({
</>
)}
</CardContent>
<CardFooter className="justify-end border-t pt-6">
<Button onClick={handleSave} disabled={isSaving}>
<CardFooter className="justify-end gap-3 border-t pt-6">
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
{isSaving ? "Saving..." : "Save Voicemail Settings"}
</Button>
</CardFooter>
@ -848,8 +897,23 @@ function WorkflowSettingsContent({
}: {
workflow: WorkflowResponse;
user: { id: string; email?: string };
}) {
return (
<UnsavedChangesProvider>
<WorkflowSettingsInner workflow={workflow} user={user} />
</UnsavedChangesProvider>
);
}
function WorkflowSettingsInner({
workflow,
user,
}: {
workflow: WorkflowResponse;
user: { id: string; email?: string };
}) {
const router = useRouter();
const { dirtySections, confirmNavigate } = useUnsavedChangesContext();
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
@ -921,7 +985,7 @@ function WorkflowSettingsContent({
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}`)}
onClick={() => confirmNavigate(() => router.push(`/workflow/${workflowId}`))}
>
<ArrowLeft className="h-4 w-4" />
</Button>
@ -1047,13 +1111,16 @@ function WorkflowSettingsContent({
<a
key={item.id}
href={`#${item.id}`}
className={`block rounded-md px-2 py-1 text-sm transition-colors hover:text-foreground ${
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors hover:text-foreground ${
activeSection === item.id
? "font-medium text-foreground"
: "text-muted-foreground"
}`}
>
{item.label}
{dirtySections.has(item.id) && (
<span className="h-1.5 w-1.5 rounded-full bg-orange-500" />
)}
</a>
))}
</div>