feat: add recording audio option in tool and node transitions (#232)

* feat: allow uploading recording as part of node transition

* feat: allow recordings in tool transitions

* chore: fix tests
This commit is contained in:
Abhishek 2026-04-10 17:53:42 +05:30 committed by GitHub
parent 3f19a16e7f
commit 7c245051d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 3575 additions and 640 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

@ -1,11 +1,18 @@
"use client";
import { ExternalLink } from "lucide-react";
import { ExternalLink, Upload } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAuth } from "@/lib/auth";
import DocumentList from "./DocumentList";
@ -14,6 +21,7 @@ import DocumentUpload from "./DocumentUpload";
export default function FilesPage() {
const { user, redirectToLogin, loading } = useAuth();
const [refreshKey, setRefreshKey] = useState(0);
const [isUploadOpen, setIsUploadOpen] = useState(false);
// Redirect if not authenticated
useEffect(() => {
@ -23,8 +31,8 @@ export default function FilesPage() {
}, [loading, user, redirectToLogin]);
const handleUploadSuccess = () => {
// Trigger refresh of document list
setRefreshKey(prev => prev + 1);
setIsUploadOpen(false);
};
if (loading || !user) {
@ -50,44 +58,37 @@ export default function FilesPage() {
</p>
</div>
<Tabs defaultValue="all" className="space-y-6">
<TabsList>
<TabsTrigger value="all">All Files</TabsTrigger>
<TabsTrigger value="upload">Upload New</TabsTrigger>
</TabsList>
<TabsContent value="all" className="space-y-4">
<Card>
<CardHeader>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>Your Documents</CardTitle>
<CardDescription>
View and manage your uploaded documents
Documents shared across all agents in your organization
</CardDescription>
</CardHeader>
<CardContent>
<DocumentList
refreshTrigger={refreshKey}
/>
</CardContent>
</Card>
</TabsContent>
</div>
<Button onClick={() => setIsUploadOpen(true)}>
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</div>
</CardHeader>
<CardContent>
<DocumentList refreshTrigger={refreshKey} />
</CardContent>
</Card>
<TabsContent value="upload" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Upload Document</CardTitle>
<CardDescription>
Upload a PDF or document file to add to your knowledge base
</CardDescription>
</CardHeader>
<CardContent>
<DocumentUpload
onUploadSuccess={handleUploadSuccess}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Dialog open={isUploadOpen} onOpenChange={setIsUploadOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
Upload a PDF or document file to add to your knowledge base
</DialogDescription>
</DialogHeader>
<DocumentUpload onUploadSuccess={handleUploadSuccess} />
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,323 @@
"use client";
import { AudioLines, Check, Pause, Pencil, Play, RefreshCw, Search, Trash2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete,
listRecordingsApiV1WorkflowRecordingsGet,
updateRecordingApiV1WorkflowRecordingsIdPatch,
} from "@/client/sdk.gen";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import logger from "@/lib/logger";
export default function RecordingsList({ refreshKey }: { refreshKey?: number }) {
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [error, setError] = useState<string | null>(null);
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const { playingId, toggle: togglePlayback, stop: stopPlayback } = useAudioPlayback();
const fetchRecordings = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const response = await listRecordingsApiV1WorkflowRecordingsGet({
query: {},
});
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);
}
}, []);
useEffect(() => {
fetchRecordings();
}, [fetchRecordings, refreshKey]);
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);
setEditError(null);
};
const cancelEditing = () => {
setEditingId(null);
setEditValue("");
setEditError(null);
};
const saveRecordingId = async (rec: RecordingResponseSchema) => {
const newId = editValue.trim();
if (!newId) {
setEditError("ID cannot be empty");
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(newId)) {
setEditError("Only letters, numbers, hyphens, and underscores");
return;
}
if (newId === rec.recording_id) {
cancelEditing();
return;
}
setEditError(null);
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}". All workflow references have been updated.`);
cancelEditing();
fetchRecordings();
} catch (err) {
setEditError(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">
{/* 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 yet"}
</p>
</div>
) : (
<div className="space-y-3">
{filteredRecordings.map((rec) => {
const filename = (rec.metadata?.original_filename as string) || "";
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 flex-wrap">
<Input
value={editValue}
onChange={(e) => { setEditValue(e.target.value); setEditError(null); }}
onKeyDown={(e) => {
if (e.key === "Enter") saveRecordingId(rec);
if (e.key === "Escape") cancelEditing();
}}
className={`h-7 text-sm font-mono w-48 ${editError ? "border-destructive" : ""}`}
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>
{editError && (
<span className="text-xs text-destructive">{editError}</span>
)}
</div>
) : (
<div className="flex items-center gap-1.5">
<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 px-1.5 text-xs text-muted-foreground gap-1"
onClick={() => startEditing(rec)}
>
<Pencil className="w-3 h-3" />
Edit ID
</Button>
</div>
)}
</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>{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,465 @@
"use client";
import { Loader2, Mic, Square, Upload, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
createRecordingsApiV1WorkflowRecordingsPost,
getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost,
transcribeAudioApiV1WorkflowRecordingsTranscribePost,
} from "@/client";
import type { RecordingUploadResponseSchema } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
interface RecordingsUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUploadComplete?: () => void;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
interface PendingFile {
id: string;
file: File;
transcript: string;
isTranscribing: boolean;
error?: string;
}
let pendingFileCounter = 0;
export const RecordingsUploadDialog = ({
open,
onOpenChange,
onUploadComplete,
}: RecordingsUploadDialogProps) => {
const [uploading, setUploading] = useState(false);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [error, setError] = useState<string | null>(null);
const [language, setLanguage] = useState("multi");
const [recordingStep, setRecordingStep] = useState<"idle" | "naming" | "recording">("idle");
const [recordingFilename, setRecordingFilename] = useState("");
const [recordingDuration, setRecordingDuration] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const languageRef = useRef(language);
languageRef.current = language;
const stopRecordingTimer = useCallback(() => {
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop();
}
}, []);
const resetRecordingState = useCallback(() => {
setRecordingStep("idle");
setRecordingFilename("");
setRecordingDuration(0);
}, []);
useEffect(() => {
if (open) {
setError(null);
setPendingFiles([]);
setLanguage("multi");
resetRecordingState();
}
}, [open, resetRecordingState]);
useEffect(() => {
if (!open) {
stopRecording();
stopRecordingTimer();
}
}, [open, stopRecording, stopRecordingTimer]);
const transcribeFile = async (pendingId: string, file: File) => {
setPendingFiles((prev) =>
prev.map((p) => (p.id === pendingId ? { ...p, isTranscribing: true } : p))
);
try {
const currentLang = languageRef.current;
const result = await transcribeAudioApiV1WorkflowRecordingsTranscribePost({
body: { file, language: currentLang },
});
const data = result.data as Record<string, unknown> | undefined;
if (data?.transcript) {
setPendingFiles((prev) =>
prev.map((p) =>
p.id === pendingId ? { ...p, transcript: data.transcript as string, isTranscribing: false } : p
)
);
} else {
setPendingFiles((prev) =>
prev.map((p) => (p.id === pendingId ? { ...p, isTranscribing: false } : p))
);
}
} catch {
setPendingFiles((prev) =>
prev.map((p) =>
p.id === pendingId
? { ...p, isTranscribing: false, error: "Auto-transcription failed" }
: p
)
);
}
};
const addPendingFiles = (files: File[]) => {
const valid: PendingFile[] = [];
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
setError(`${file.name} (${(file.size / (1024 * 1024)).toFixed(1)}MB) exceeds 5MB limit — skipped.`);
continue;
}
const id = `pending-${++pendingFileCounter}`;
valid.push({ id, file, transcript: "", isTranscribing: false });
}
if (valid.length === 0) return;
setPendingFiles((prev) => [...prev, ...valid]);
setError(null);
for (const pf of valid) {
transcribeFile(pf.id, pf.file);
}
};
const removePendingFile = (pendingId: string) => {
setPendingFiles((prev) => prev.filter((p) => p.id !== pendingId));
};
const updateTranscript = (pendingId: string, transcript: string) => {
setPendingFiles((prev) =>
prev.map((p) => (p.id === pendingId ? { ...p, transcript } : p))
);
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunksRef.current.push(e.data);
};
const filename = recordingFilename.trim() || "recording";
mediaRecorder.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
stopRecordingTimer();
const blob = new Blob(audioChunksRef.current, { type: mediaRecorder.mimeType });
if (blob.size > MAX_FILE_SIZE) {
setError(`Recording (${(blob.size / (1024 * 1024)).toFixed(1)}MB) exceeds the maximum allowed size of 5MB.`);
resetRecordingState();
return;
}
const ext = mediaRecorder.mimeType.includes("webm") ? "webm" : "mp4";
const file = new File([blob], `${filename}.${ext}`, { type: mediaRecorder.mimeType });
resetRecordingState();
addPendingFiles([file]);
};
mediaRecorder.start();
setRecordingStep("recording");
setRecordingDuration(0);
setError(null);
recordingTimerRef.current = setInterval(() => {
setRecordingDuration((d) => d + 1);
}, 1000);
} catch {
setError("Microphone access denied. Please allow microphone permissions.");
resetRecordingState();
}
};
const handleFileSelect = (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
addPendingFiles(Array.from(fileList));
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleUpload = async () => {
const ready = pendingFiles.filter((p) => p.transcript.trim() && !p.isTranscribing);
if (ready.length === 0) return;
setUploading(true);
setError(null);
try {
const uploadUrlResponse = await getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost({
body: {
files: ready.map((p) => ({
filename: p.file.name,
mime_type: p.file.type || "audio/wav",
file_size: p.file.size,
})),
},
});
if (!uploadUrlResponse.data?.items) {
throw new Error("Failed to get upload URLs");
}
const items = uploadUrlResponse.data.items;
await Promise.all(
items.map(async (item: RecordingUploadResponseSchema, idx: number) => {
const file = ready[idx].file;
const uploadResponse = await fetch(item.upload_url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type || "audio/wav" },
});
if (!uploadResponse.ok) {
throw new Error(`File upload failed for ${file.name}`);
}
})
);
await createRecordingsApiV1WorkflowRecordingsPost({
body: {
recordings: items.map((item: RecordingUploadResponseSchema, idx: number) => ({
recording_id: item.recording_id,
transcript: ready[idx].transcript.trim(),
storage_key: item.storage_key,
metadata: {
original_filename: ready[idx].file.name,
file_size_bytes: ready[idx].file.size,
mime_type: ready[idx].file.type,
language,
},
})),
},
});
setPendingFiles([]);
setLanguage("multi");
resetRecordingState();
if (fileInputRef.current) fileInputRef.current.value = "";
onUploadComplete?.();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to upload recordings");
} finally {
setUploading(false);
}
};
const isRecording = recordingStep === "recording";
const anyTranscribing = pendingFiles.some((p) => p.isTranscribing);
const readyCount = pendingFiles.filter((p) => p.transcript.trim() && !p.isTranscribing).length;
const isBusy = uploading || isRecording || anyTranscribing;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Upload Recordings</DialogTitle>
<DialogDescription>
Upload or record audio files. Use{" "}
<code className="text-xs bg-muted px-1 rounded">@</code> in
prompt fields to insert them into your agents.
</DialogDescription>
</DialogHeader>
{error && (
<div className="text-sm text-destructive bg-destructive/10 rounded-md p-2">
{error}
</div>
)}
{/* Upload Section */}
<div className="space-y-3">
{/* Audio source: file picker or record */}
<div>
<Label className="text-xs text-muted-foreground">Audio Files</Label>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="audio/*"
multiple
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
className="flex-1 justify-start text-sm font-normal"
onClick={() => fileInputRef.current?.click()}
disabled={isBusy}
>
<Upload className="w-4 h-4 mr-2 shrink-0" />
<span className="text-muted-foreground">Choose audio files (max 5MB each)</span>
</Button>
{recordingStep === "idle" && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRecordingStep("naming")}
disabled={uploading || anyTranscribing}
>
<Mic className="w-4 h-4 mr-1" />
Record
</Button>
)}
</div>
</div>
{/* Recording: filename + start/stop */}
{(recordingStep === "naming" || isRecording) && (
<div className="space-y-2 rounded-md border border-dashed p-3 bg-muted/20">
{recordingStep === "naming" && (
<>
<div>
<Label className="text-xs text-muted-foreground">Recording Name</Label>
<Input
placeholder="e.g. greeting, hold-message"
value={recordingFilename}
onChange={(e) => setRecordingFilename(e.target.value)}
autoFocus
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={startRecording} disabled={!recordingFilename.trim()}>
<Mic className="w-4 h-4 mr-1" />
Start Recording
</Button>
<Button size="sm" variant="ghost" onClick={resetRecordingState}>
Cancel
</Button>
</div>
</>
)}
{isRecording && (
<div className="flex items-center gap-3">
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500" />
</span>
<span className="text-sm font-mono">
{Math.floor(recordingDuration / 60)}:{(recordingDuration % 60).toString().padStart(2, "0")}
</span>
<span className="text-xs text-muted-foreground">{recordingFilename}</span>
<Button
size="sm"
variant="destructive"
onClick={() => stopRecording()}
className="ml-auto"
>
<Square className="w-4 h-4 mr-1" />
Stop
</Button>
</div>
)}
</div>
)}
{/* Pending files list */}
{pendingFiles.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Pending ({pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""})
</Label>
{pendingFiles.map((pf) => (
<div key={pf.id} className="rounded-md border p-2 space-y-1.5 bg-muted/10">
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono truncate flex-1">
{pf.file.name} ({(pf.file.size / (1024 * 1024)).toFixed(1)}MB)
</code>
{pf.isTranscribing && (
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0" />
)}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 shrink-0"
onClick={() => removePendingFile(pf.id)}
disabled={uploading}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
{pf.error && (
<p className="text-xs text-destructive">{pf.error}</p>
)}
<Textarea
placeholder={pf.isTranscribing ? "Transcribing..." : "What does this recording say?"}
value={pf.transcript}
onChange={(e) => updateTranscript(pf.id, e.target.value)}
disabled={pf.isTranscribing}
rows={2}
className="resize-none text-sm"
/>
</div>
))}
</div>
)}
{/* Language */}
<div>
<Label className="text-xs text-muted-foreground">Language</Label>
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(LANGUAGE_DISPLAY_NAMES).map(([code, name]) => (
<SelectItem key={code} value={code}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
onClick={handleUpload}
disabled={readyCount === 0 || isBusy}
>
{uploading ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-1" />
)}
{uploading
? "Uploading..."
: `Upload ${readyCount} Recording${readyCount !== 1 ? "s" : ""}`}
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,77 @@
"use client";
import { ExternalLink, Upload } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
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";
import { RecordingsUploadDialog } from "./RecordingsUploadDialog";
export default function RecordingsPage() {
const { user, redirectToLogin, loading } = useAuth();
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
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">
Manage audio recordings for your organization. Use{" "}
<code className="rounded bg-muted px-1 text-xs">@</code> in prompt fields to insert them,
or as transition messages in tool calls.{" "}
<a href="https://docs.dograh.com/voice-agent/pre-recorded-audio" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">
Learn more <ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>All Recordings</CardTitle>
<CardDescription>
Audio recordings shared across all agents in your organization
</CardDescription>
</div>
<Button onClick={() => setIsUploadOpen(true)}>
<Upload className="w-4 h-4 mr-2" />
Upload Recording
</Button>
</div>
</CardHeader>
<CardContent>
<RecordingsList refreshKey={refreshKey} />
</CardContent>
</Card>
<RecordingsUploadDialog
open={isUploadOpen}
onOpenChange={setIsUploadOpen}
onUploadComplete={() => setRefreshKey((k) => k + 1)}
/>
</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

@ -6,9 +6,10 @@ import { useCallback, useEffect, useState } from "react";
import {
getToolApiV1ToolsToolUuidGet,
listRecordingsApiV1WorkflowRecordingsGet,
updateToolApiV1ToolsToolUuidPut,
} from "@/client/sdk.gen";
import type { ToolResponse, TransferCallConfig as APITransferCallConfig } from "@/client/types.gen";
import type { RecordingResponseSchema, ToolResponse, TransferCallConfig as APITransferCallConfig } from "@/client/types.gen";
import type { EndCallConfig } from "@/client/types.gen";
import { type HttpMethod, type KeyValueItem, type ToolParameter, validateUrl } from "@/components/http";
import { Button } from "@/components/ui/button";
@ -75,6 +76,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 +89,14 @@ 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("");
// Org-level recordings for audio dropdowns
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
// Redirect if not authenticated
useEffect(() => {
@ -132,11 +142,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 +160,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 +179,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) {
@ -193,9 +213,24 @@ export default function ToolDetailPage() {
}
};
const fetchRecordings = useCallback(async () => {
if (loading || !user) return;
try {
const response = await listRecordingsApiV1WorkflowRecordingsGet({
query: {},
});
if (response.data) {
setRecordings(response.data.recordings);
}
} catch {
// Non-critical — dropdowns will show "No recordings available"
}
}, [loading, user]);
useEffect(() => {
fetchTool();
}, [fetchTool]);
fetchRecordings();
}, [fetchTool, fetchRecordings]);
const handleSave = async () => {
if (!tool) return;
@ -259,6 +294,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 +312,7 @@ export default function ToolDetailPage() {
destination: transferDestination,
messageType: transferMessageType,
customMessage: transferMessageType === "custom" ? customMessage : undefined,
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
timeout: transferTimeout,
},
},
@ -306,7 +343,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 +529,9 @@ const data = await response.json();`;
onMessageTypeChange={setEndCallMessageType}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
audioRecordingId={audioRecordingId}
onAudioRecordingIdChange={setAudioRecordingId}
recordings={recordings}
endCallReason={endCallReason}
onEndCallReasonChange={handleEndCallReasonChange}
endCallReasonDescription={endCallReasonDescription}
@ -507,6 +549,9 @@ const data = await response.json();`;
onMessageTypeChange={setTransferMessageType}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
audioRecordingId={transferAudioRecordingId}
onAudioRecordingIdChange={setTransferAudioRecordingId}
recordings={recordings}
timeout={transferTimeout}
onTimeoutChange={setTransferTimeout}
/>
@ -530,6 +575,11 @@ const data = await response.json();`;
onTimeoutMsChange={setTimeoutMs}
customMessage={customMessage}
onCustomMessageChange={setCustomMessage}
customMessageType={customMessageType}
onCustomMessageTypeChange={setCustomMessageType}
customMessageRecordingId={customMessageRecordingId}
onCustomMessageRecordingIdChange={setCustomMessageRecordingId}
recordings={recordings}
/>
)}

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

@ -15,7 +15,6 @@ import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } fr
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useUserConfig } from '@/context/UserConfigContext';
import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
@ -64,12 +63,6 @@ interface RenderWorkflowProps {
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
const router = useRouter();
const { userConfig } = useUserConfig();
const ttsOverrides = initialWorkflowConfigurations?.model_overrides?.tts;
const ttsProvider = ttsOverrides?.provider ?? (userConfig?.tts?.provider as string) ?? "";
const ttsModel = ttsOverrides?.model ?? (userConfig?.tts?.model as string) ?? "";
const ttsVoiceId = ttsOverrides?.voice ?? (userConfig?.tts?.voice as string) ?? "";
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
@ -245,15 +238,10 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
setTools(toolsResponse.data);
}
// Fetch recordings for this workflow filtered by active TTS config
// Fetch org-level recordings
try {
const recordingsResponse = await listRecordingsApiV1WorkflowRecordingsGet({
query: {
workflow_id: workflowId,
tts_provider: ttsProvider || undefined,
tts_model: ttsModel || undefined,
tts_voice_id: ttsVoiceId || undefined,
},
query: {},
});
if (recordingsResponse.data) {
setRecordings(recordingsResponse.data.recordings);
@ -267,7 +255,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
};
fetchData();
}, [workflowId, ttsProvider, ttsModel, ttsVoiceId]);
}, [workflowId]);
// Memoize defaultEdgeOptions to prevent unnecessary re-renders
const defaultEdgeOptions = useMemo(() => ({

View file

@ -62,7 +62,6 @@ let pendingFileCounter = 0;
export const RecordingsDialog = ({
open,
onOpenChange,
workflowId,
onRecordingsChange,
ttsOverrides,
}: RecordingsDialogProps) => {
@ -89,12 +88,10 @@ export const RecordingsDialog = ({
const ttsVoiceId = ttsOverrides?.voice ?? (userConfig?.tts?.voice as string) ?? "";
const fetchRecordings = useCallback(async () => {
if (!workflowId) return;
setLoading(true);
try {
const result = await listRecordingsApiV1WorkflowRecordingsGet({
query: {
workflow_id: workflowId,
tts_provider: ttsProvider || undefined,
tts_model: ttsModel || undefined,
tts_voice_id: ttsVoiceId || undefined,
@ -108,7 +105,7 @@ export const RecordingsDialog = ({
} finally {
setLoading(false);
}
}, [workflowId, ttsProvider, ttsModel, ttsVoiceId, onRecordingsChange]);
}, [ttsProvider, ttsModel, ttsVoiceId, onRecordingsChange]);
const stopRecordingTimer = useCallback(() => {
if (recordingTimerRef.current) {
@ -277,7 +274,6 @@ export const RecordingsDialog = ({
const uploadUrlResponse =
await getUploadUrlsApiV1WorkflowRecordingsUploadUrlPost({
body: {
workflow_id: workflowId,
files: ready.map((p) => ({
filename: p.file.name,
mime_type: p.file.type || "audio/wav",
@ -314,7 +310,6 @@ export const RecordingsDialog = ({
body: {
recordings: items.map((item: RecordingUploadResponseSchema, idx: number) => ({
recording_id: item.recording_id,
workflow_id: workflowId,
tts_provider: ttsProvider,
tts_model: ttsModel,
tts_voice_id: ttsVoiceId,

View file

@ -446,6 +446,17 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
if (!firstBotSpeechCompletedRef.current) {
firstBotSpeechCompletedRef.current = true;
}
// Finalize the last bot message so "speaking..." indicator is removed
setFeedbackMessages(prev => {
const lastIdx = prev.length - 1;
const last = prev[lastIdx];
if (last && last.type === 'bot-text' && !last.final) {
const updated = [...prev];
updated[lastIdx] = { ...last, final: true };
return updated;
}
return prev;
});
break;
case 'rtf-user-mute-started':

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowLeft, BookA, Brain, ExternalLink, Loader2, Mic, Pause, PhoneOff, Play, Rocket, Settings, Trash2Icon, Upload, Variable, X } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
@ -19,6 +20,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";
@ -32,7 +34,6 @@ import {
} from "@/types/workflow-configurations";
import { EmbedDialog } from "../components/EmbedDialog";
import { RecordingsDialog } from "../components/RecordingsDialog";
import { useWorkflowState } from "../hooks/useWorkflowState";
// ---------------------------------------------------------------------------
@ -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>
@ -849,9 +898,23 @@ function WorkflowSettingsContent({
workflow: WorkflowResponse;
user: { id: string; email?: string };
}) {
const router = useRouter();
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);
const [activeSection, setActiveSection] = useState("general");
@ -921,7 +984,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>
@ -993,7 +1056,7 @@ function WorkflowSettingsContent({
onSave={saveWorkflowConfigurations}
/>
{/* Recordings (dialog trigger) */}
{/* Recordings moved to org-level page */}
<Card id="recordings">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@ -1001,15 +1064,17 @@ function WorkflowSettingsContent({
Recordings
</CardTitle>
<CardDescription>
Upload or record audio for hybrid prompts. Use{" "}
<code className="rounded bg-muted px-1 text-xs">@</code> in prompt fields to
insert them.{" "}
Recordings are now managed at the organization level and shared across all agents.
Use <code className="rounded bg-muted px-1 text-xs">@</code> in prompt fields to insert them.{" "}
<a href={SETTINGS_DOCUMENTATION_URLS.recordings} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
</CardDescription>
</CardHeader>
<CardFooter className="border-t pt-6">
<Button variant="outline" onClick={() => setIsRecordingsDialogOpen(true)}>
Manage Recordings
<Button variant="outline" asChild>
<Link href="/recordings">
Go to Recordings
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardFooter>
</Card>
@ -1047,13 +1112,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>
@ -1061,12 +1129,6 @@ function WorkflowSettingsContent({
</div>
{/* Dialogs for complex sections */}
<RecordingsDialog
open={isRecordingsDialogOpen}
onOpenChange={setIsRecordingsDialogOpen}
workflowId={workflowId}
ttsOverrides={workflowConfigurations?.model_overrides?.tts}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}