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}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -269,12 +269,6 @@ export type BatchRecordingCreateResponseSchema = {
* Request schema for getting presigned upload URLs for one or more files.
*/
export type BatchRecordingUploadRequestSchema = {
/**
* Workflow Id
*
* Workflow ID these recordings belong to
*/
workflow_id: number;
/**
* Files
*
@ -1572,13 +1566,19 @@ export type EndCallConfig = {
*
* Type of goodbye message
*/
messageType?: 'none' | 'custom';
messageType?: 'none' | 'custom' | 'audio';
/**
* Custommessage
*
* Custom message to play before ending the call
*/
customMessage?: string | null;
/**
* Audiorecordingid
*
* Recording ID for audio goodbye message
*/
audioRecordingId?: string | null;
/**
* Endcallreason
*
@ -1739,6 +1739,24 @@ export type HttpApiConfig = {
* Request timeout in milliseconds
*/
timeout_ms?: number | null;
/**
* Custommessage
*
* Custom message to play after tool execution
*/
customMessage?: string | null;
/**
* Custommessagetype
*
* Type of custom message: text or audio
*/
customMessageType?: 'text' | 'audio' | null;
/**
* Custommessagerecordingid
*
* Recording ID for audio custom message
*/
customMessageRecordingId?: string | null;
};
/**
@ -2102,30 +2120,24 @@ export type RecordingCreateRequestSchema = {
* Short recording ID from upload step
*/
recording_id: string;
/**
* Workflow Id
*
* Workflow ID
*/
workflow_id: number;
/**
* Tts Provider
*
* TTS provider (e.g. elevenlabs)
*/
tts_provider: string;
tts_provider?: string | null;
/**
* Tts Model
*
* TTS model name
*/
tts_model: string;
tts_model?: string | null;
/**
* Tts Voice Id
*
* TTS voice identifier
*/
tts_voice_id: string;
tts_voice_id?: string | null;
/**
* Transcript
*
@ -2181,7 +2193,7 @@ export type RecordingResponseSchema = {
/**
* Workflow Id
*/
workflow_id: number;
workflow_id?: number | null;
/**
* Organization Id
*/
@ -2189,15 +2201,15 @@ export type RecordingResponseSchema = {
/**
* Tts Provider
*/
tts_provider: string;
tts_provider?: string | null;
/**
* Tts Model
*/
tts_model: string;
tts_model?: string | null;
/**
* Tts Voice Id
*/
tts_voice_id: string;
tts_voice_id?: string | null;
/**
* Transcript
*/
@ -2230,6 +2242,20 @@ export type RecordingResponseSchema = {
is_active: boolean;
};
/**
* RecordingUpdateRequestSchema
*
* Request schema for updating a recording's ID.
*/
export type RecordingUpdateRequestSchema = {
/**
* Recording Id
*
* New descriptive recording ID (letters, numbers, hyphens, underscores only)
*/
recording_id: string;
};
/**
* RecordingUploadResponseSchema
*
@ -2814,13 +2840,19 @@ export type TransferCallConfig = {
*
* Type of message to play before transfer
*/
messageType?: 'none' | 'custom';
messageType?: 'none' | 'custom' | 'audio';
/**
* Custommessage
*
* Custom message to play before transferring the call
*/
customMessage?: string | null;
/**
* Audiorecordingid
*
* Recording ID for audio message before transfer
*/
audioRecordingId?: string | null;
/**
* Timeout
*
@ -8885,13 +8917,13 @@ export type ListRecordingsApiV1WorkflowRecordingsGetData = {
'X-API-Key'?: string | null;
};
path?: never;
query: {
query?: {
/**
* Workflow Id
*
* Workflow ID
* Filter by workflow ID
*/
workflow_id: number;
workflow_id?: number | null;
/**
* Tts Provider
*
@ -9017,6 +9049,50 @@ export type DeleteRecordingApiV1WorkflowRecordingsRecordingIdDeleteResponses = {
200: unknown;
};
export type UpdateRecordingApiV1WorkflowRecordingsIdPatchData = {
body: RecordingUpdateRequestSchema;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Id
*/
id: number;
};
query?: never;
url: '/api/v1/workflow-recordings/{id}';
};
export type UpdateRecordingApiV1WorkflowRecordingsIdPatchErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateRecordingApiV1WorkflowRecordingsIdPatchError = UpdateRecordingApiV1WorkflowRecordingsIdPatchErrors[keyof UpdateRecordingApiV1WorkflowRecordingsIdPatchErrors];
export type UpdateRecordingApiV1WorkflowRecordingsIdPatchResponses = {
/**
* Successful Response
*/
200: RecordingResponseSchema;
};
export type UpdateRecordingApiV1WorkflowRecordingsIdPatchResponse = UpdateRecordingApiV1WorkflowRecordingsIdPatchResponses[keyof UpdateRecordingApiV1WorkflowRecordingsIdPatchResponses];
export type TranscribeAudioApiV1WorkflowRecordingsTranscribePostData = {
body: BodyTranscribeAudioApiV1WorkflowRecordingsTranscribePost;
headers?: {

View file

@ -0,0 +1,212 @@
import { Check, ChevronDown, Pause, Play, Search } from "lucide-react";
import { useMemo, useState } from "react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContentInline, PopoverTrigger } from "@/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { cn } from "@/lib/utils";
interface TextOrAudioInputProps {
type: 'text' | 'audio';
onTypeChange: (type: 'text' | 'audio') => void;
recordingId: string;
onRecordingIdChange: (id: string) => void;
recordings?: RecordingResponseSchema[];
/** Rendered when type === 'text' */
children: React.ReactNode;
}
export function TextOrAudioInput({
type,
onTypeChange,
recordingId,
onRecordingIdChange,
recordings = [],
children,
}: TextOrAudioInputProps) {
return (
<>
<RadioGroup
value={type}
onValueChange={(value) => onTypeChange(value as 'text' | 'audio')}
className="flex items-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="text" id="toa-text" />
<Label htmlFor="toa-text" className="font-normal cursor-pointer">Text</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="audio" id="toa-audio" />
<Label htmlFor="toa-audio" className="font-normal cursor-pointer">Audio</Label>
</div>
</RadioGroup>
{type === 'text' ? (
children
) : (
<RecordingSelect
value={recordingId}
onChange={onRecordingIdChange}
recordings={recordings}
/>
)}
</>
);
}
interface RecordingSelectProps {
value: string;
onChange: (id: string) => void;
recordings: RecordingResponseSchema[];
}
/**
* Dropdown to select a pre-recorded audio file.
* Re-exported so callers that only need the dropdown (e.g. tool configs with
* their own none/custom/audio radio) can use it directly.
*/
export function RecordingSelect({ value, onChange, recordings }: RecordingSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const { playingId, toggle, stop } = useAudioPlayback();
const selected = recordings.find((r) => String(r.id) === value);
const filtered = useMemo(() => {
if (!search) return recordings;
const q = search.toLowerCase();
return recordings.filter((r) =>
r.recording_id.toLowerCase().includes(q) ||
r.transcript.toLowerCase().includes(q) ||
((r.metadata?.original_filename as string) || "").toLowerCase().includes(q)
);
}, [recordings, search]);
const handleSelect = (rec: RecordingResponseSchema) => {
stop();
onChange(String(rec.id));
setOpen(false);
};
const handlePlay = async (e: React.MouseEvent, rec: RecordingResponseSchema) => {
e.stopPropagation();
try {
await toggle(rec.recording_id, rec.storage_key, rec.storage_backend);
} catch {
// Ignore playback errors
}
};
return (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Select a pre-recorded audio file to play.
</Label>
<Popover modal open={open} onOpenChange={(v) => { if (!v) { stop(); setSearch(""); } setOpen(v); }}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between h-auto min-h-9 font-normal"
>
{selected ? (
<span className="flex items-center gap-2 text-left">
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono shrink-0">
{selected.recording_id}
</code>
<span className="text-sm">
{selected.transcript.length > 75
? `${selected.transcript.slice(0, 75)}`
: selected.transcript}
</span>
</span>
) : (
<span className="text-muted-foreground">Select a recording</span>
)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContentInline
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
{recordings.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
No recordings available
</div>
) : (
<div>
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by ID, transcript, or filename..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-8 text-sm"
autoFocus
/>
</div>
</div>
<div className="max-h-56 overflow-y-auto">
{filtered.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
No recordings match &ldquo;{search}&rdquo;
</div>
) : filtered.map((r) => {
const filename = (r.metadata?.original_filename as string) || "";
const isSelected = String(r.id) === value;
const isPlaying = playingId === r.recording_id;
return (
<div
key={r.id}
className={cn(
"flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-accent transition-colors",
isSelected && "bg-accent"
)}
onClick={() => handleSelect(r)}
>
<Check className={cn(
"h-4 w-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0"
)} />
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono shrink-0">
{r.recording_id}
</code>
{filename && (
<span className="text-xs text-muted-foreground shrink-0 max-w-[100px] truncate">
{filename}
</span>
)}
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded truncate flex-1 min-w-0">
{r.transcript}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={(e) => handlePlay(e, r)}
>
{isPlaying ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
</div>
);
})}
</div>
</div>
)}
</PopoverContentInline>
</Popover>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useWorkflow, useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -24,9 +25,12 @@ interface EdgeDetailsDialogProps {
const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDialogProps) => {
const readOnly = useWorkflowOptional()?.readOnly ?? false;
const { recordings } = useWorkflow();
const [condition, setCondition] = useState(data?.condition ?? '');
const [label, setLabel] = useState(data?.label ?? '');
const [transitionSpeech, setTransitionSpeech] = useState(data?.transition_speech ?? '');
const [transitionSpeechType, setTransitionSpeechType] = useState<'text' | 'audio'>(data?.transition_speech_type ?? 'text');
const [transitionSpeechRecordingId, setTransitionSpeechRecordingId] = useState(data?.transition_speech_recording_id ?? '');
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
@ -34,13 +38,21 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
setCondition(data?.condition ?? '');
setLabel(data?.label ?? '');
setTransitionSpeech(data?.transition_speech ?? '');
setTransitionSpeechType(data?.transition_speech_type ?? 'text');
setTransitionSpeechRecordingId(data?.transition_speech_recording_id ?? '');
}
}, [data, open]);
const handleSave = useCallback(() => {
onSave({ condition: condition, label: label, transition_speech: transitionSpeech || undefined });
onSave({
condition,
label,
transition_speech: transitionSpeechType === 'text' ? (transitionSpeech || undefined) : undefined,
transition_speech_type: transitionSpeechType,
transition_speech_recording_id: transitionSpeechType === 'audio' ? (transitionSpeechRecordingId || undefined) : undefined,
});
onOpenChange(false);
}, [condition, label, transitionSpeech, onSave, onOpenChange]);
}, [condition, label, transitionSpeech, transitionSpeechType, transitionSpeechRecordingId, onSave, onOpenChange]);
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
useEffect(() => {
@ -60,7 +72,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Edit Condition</DialogTitle>
{data?.invalid && data.validationMessage && (
@ -70,7 +82,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
</div>
)}
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-4 py-4 overflow-y-auto">
<div className="grid gap-2">
<Label>Condition Label</Label>
<Label className="text-xs text-muted-foreground">
@ -99,18 +111,28 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
<div className="grid gap-2">
<Label>Transition Speech</Label>
<Label className="text-xs text-muted-foreground">
Optional text the assistant will speak right before transitioning to the node.
This text will not be attached in Conversation Context. Use this as simple filler to reduce latency.
Optional text or audio the assistant will play right before transitioning to the node.
This will not be attached in Conversation Context. Use this as simple filler to reduce latency.
</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={transitionSpeech}
placeholder="e.g. Let me transfer you to our billing department..."
onChange={(e) => setTransitionSpeech(e.target.value)}
/>
<TextOrAudioInput
type={transitionSpeechType}
onTypeChange={setTransitionSpeechType}
recordingId={transitionSpeechRecordingId}
onRecordingIdChange={setTransitionSpeechRecordingId}
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={transitionSpeech}
placeholder="e.g. Let me transfer you to our billing department..."
onChange={(e) => setTransitionSpeech(e.target.value)}
/>
</>
</TextOrAudioInput>
</div>
</div>
<DialogFooter>

View file

@ -8,6 +8,7 @@ import type { RecordingResponseSchema } from "@/client/types.gen";
import { DocumentBadges } from "@/components/flow/DocumentBadges";
import { DocumentSelector } from "@/components/flow/DocumentSelector";
import { MentionTextarea } from "@/components/flow/MentionTextarea";
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import { ToolBadges } from "@/components/flow/ToolBadges";
import { ToolSelector } from "@/components/flow/ToolSelector";
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
@ -26,8 +27,12 @@ import { useNodeHandlers } from "./common/useNodeHandlers";
interface StartCallEditFormProps {
nodeData: FlowNodeData;
greetingType: 'text' | 'audio';
setGreetingType: (value: 'text' | 'audio') => void;
greeting: string;
setGreeting: (value: string) => void;
greetingRecordingId: string;
setGreetingRecordingId: (value: string) => void;
prompt: string;
setPrompt: (value: string) => void;
name: string;
@ -73,7 +78,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
const { saveWorkflow, tools, documents, recordings } = useWorkflow();
// Form state
const [greetingType, setGreetingType] = useState<'text' | 'audio'>(data.greeting_type ?? "text");
const [greeting, setGreeting] = useState(data.greeting ?? "");
const [greetingRecordingId, setGreetingRecordingId] = useState(data.greeting_recording_id ?? "");
const [prompt, setPrompt] = useState(data.prompt ?? "");
const [name, setName] = useState(data.name);
const [allowInterrupt, setAllowInterrupt] = useState(data.allow_interrupt ?? true);
@ -109,7 +116,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
handleSaveNodeData({
...data,
greeting: greeting || undefined,
greeting_type: greetingType,
greeting: greetingType === 'text' ? (greeting || undefined) : undefined,
greeting_recording_id: greetingType === 'audio' ? (greetingRecordingId || undefined) : undefined,
prompt,
name,
allow_interrupt: allowInterrupt,
@ -132,7 +141,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setGreetingType(data.greeting_type ?? "text");
setGreeting(data.greeting ?? "");
setGreetingRecordingId(data.greeting_recording_id ?? "");
setPrompt(data.prompt ?? "");
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
@ -154,7 +165,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setGreetingType(data.greeting_type ?? "text");
setGreeting(data.greeting ?? "");
setGreetingRecordingId(data.greeting_recording_id ?? "");
setPrompt(data.prompt ?? "");
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
@ -247,8 +260,12 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
{open && (
<StartCallEditForm
nodeData={data}
greetingType={greetingType}
setGreetingType={setGreetingType}
greeting={greeting}
setGreeting={setGreeting}
greetingRecordingId={greetingRecordingId}
setGreetingRecordingId={setGreetingRecordingId}
prompt={prompt}
setPrompt={setPrompt}
name={name}
@ -288,8 +305,12 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
});
const StartCallEditForm = ({
greetingType,
setGreetingType,
greeting,
setGreeting,
greetingRecordingId,
setGreetingRecordingId,
prompt,
setPrompt,
name,
@ -362,15 +383,22 @@ const StartCallEditForm = ({
<Label>Greeting</Label>
<Label className="text-xs text-muted-foreground">
Optional greeting message played via TTS when the call starts. If set, this will be spoken directly instead of generating a response from the LLM. Supports template variables like {"{{variable_name}}"}.
Optional greeting played when the call starts. Choose between a text message (spoken via TTS) or a pre-recorded audio file.
</Label>
<MentionTextarea
value={greeting}
onChange={setGreeting}
className="min-h-[60px] max-h-[200px] resize-none overflow-y-auto"
placeholder="e.g. Hello {{first_name}}, this is Sarah calling from Acme Corp."
<TextOrAudioInput
type={greetingType}
onTypeChange={setGreetingType}
recordingId={greetingRecordingId}
onRecordingIdChange={setGreetingRecordingId}
recordings={recordings}
/>
>
<Textarea
value={greeting}
onChange={(e) => setGreeting(e.target.value)}
className="min-h-[60px] max-h-[200px] resize-none overflow-y-auto"
placeholder="e.g. Hello {{first_name}}, this is Sarah calling from Acme Corp."
/>
</TextOrAudioInput>
<Label>Prompt</Label>
<Label className="text-xs text-muted-foreground">

View file

@ -24,6 +24,8 @@ export type FlowNodeData = {
extraction_variables?: ExtractionVariable[];
add_global_prompt?: boolean;
greeting?: string;
greeting_type?: 'text' | 'audio';
greeting_recording_id?: string;
wait_for_user_greeting?: boolean;
detect_voicemail?: boolean;
delayed_start?: boolean;
@ -79,6 +81,8 @@ export type FlowEdgeData = {
condition: string;
label: string;
transition_speech?: string;
transition_speech_type?: 'text' | 'audio';
transition_speech_recording_id?: string;
invalid?: boolean;
validationMessage?: string | null;
}

View file

@ -2,6 +2,7 @@
import type { Team } from "@stackframe/stack";
import {
AudioLines,
Brain,
ChevronLeft,
ChevronRight,
@ -135,6 +136,11 @@ export function AppSidebar() {
url: "/files",
icon: Database,
},
{
title: "Recordings",
url: "/recordings",
icon: AudioLines,
},
// {
// title: "Integrations",
// url: "/integrations",

View file

@ -56,6 +56,23 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
onOpenAutoFocus={e => e.preventDefault()}
onCloseAutoFocus={() => {
document.body.style.pointerEvents = "";
}}
onPointerDownOutside={(e) => {
// Prevent the Dialog from closing when the user clicks inside a
// portaled Radix Popover/DropdownMenu rendered on top of this Dialog.
const target = e.target as HTMLElement;
if (target.closest('[data-radix-popper-content-wrapper]')) {
e.preventDefault();
}
}}
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-radix-popper-content-wrapper]')) {
e.preventDefault();
}
}}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",

View file

@ -17,6 +17,9 @@ function PopoverTrigger({
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
const popoverContentClass =
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden"
function PopoverContent({
className,
align = "center",
@ -29,20 +32,38 @@ function PopoverContent({
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
className={cn(popoverContentClass, className)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
/**
* PopoverContent without a Portal wrapper. Renders inline in the DOM tree,
* which avoids focus-trap conflicts when used inside a Dialog.
*/
function PopoverContentInline({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(popoverContentClass, className)}
{...props}
/>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverAnchor,PopoverContent, PopoverTrigger }
export { Popover, PopoverAnchor, PopoverContent, PopoverContentInline, PopoverTrigger }

View file

@ -0,0 +1,269 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
interface UnsavedChangesContextValue {
register: (id: string, isDirty: boolean) => void;
unregister: (id: string) => void;
hasDirtyChanges: boolean;
dirtySections: Set<string>;
/** Wrap programmatic navigation (e.g. router.push) to guard against unsaved changes. */
confirmNavigate: (navigate: () => void) => void;
}
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
/**
* Wraps a page to guard against accidental navigation when sections have
* unsaved changes. Intercepts:
*
* - Browser back / forward (`popstate` with history-state tracking)
* - In-app link clicks (document-level click capture on `<a>` tags)
*
* Sections register via the `useUnsavedChanges` hook.
*/
export function UnsavedChangesProvider({ children }: { children: React.ReactNode }) {
const [dirtySections, setDirtySections] = useState<Set<string>>(new Set());
const [showDialog, setShowDialog] = useState(false);
const pendingNavigate = useRef<(() => void) | null>(null);
const hasDirtyChanges = dirtySections.size > 0;
const hasDirtyRef = useRef(hasDirtyChanges);
hasDirtyRef.current = hasDirtyChanges;
// -- Section registration ------------------------------------------------
const register = useCallback((id: string, isDirty: boolean) => {
setDirtySections((prev) => {
const next = new Set(prev);
if (isDirty) next.add(id);
else next.delete(id);
return next;
});
}, []);
const unregister = useCallback((id: string) => {
setDirtySections((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
// -- Helper: prompt or proceed -------------------------------------------
const askOrProceed = useCallback((proceed: () => void) => {
if (!hasDirtyRef.current) {
proceed();
return;
}
pendingNavigate.current = proceed;
setTimeout(() => setShowDialog(true), 0);
}, []);
// -- 1. Intercept <a> clicks in capture phase -----------------------------
//
// Next.js <Link> renders <a> tags. By listening in the capture phase we
// intercept the click before React / Next.js processes it. If the user
// confirms, we navigate programmatically via window.location.
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!hasDirtyRef.current) return;
const target = e.target as HTMLElement;
const link = target.closest("a[href]") as HTMLAnchorElement | null;
if (!link) return;
const href = link.getAttribute("href");
if (!href) return;
// Skip external links
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//")) return;
// Skip hash-only links (in-page anchors)
if (href.startsWith("#")) return;
// Skip links that open in a new tab/window
if (link.target && link.target !== "_self") return;
// Skip download links
if (link.hasAttribute("download")) return;
// Skip if modifier keys are held (Ctrl+click, Cmd+click, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
// Skip non-left clicks
if (e.button !== 0) return;
// Block the navigation and ask the user
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
askOrProceed(() => {
// Navigate after user confirms
window.location.href = href;
});
};
// Capture phase so we fire before React / Next.js handlers
document.addEventListener("click", handleClick, true);
return () => document.removeEventListener("click", handleClick, true);
}, [askOrProceed]);
// -- 3. Browser back / forward (`popstate`) ------------------------------
//
// When the browser fires popstate the URL has already changed. We push
// the current page back onto the stack to "undo" the navigation, then
// show the dialog. If confirmed, we call history.back() for real.
useLayoutEffect(() => {
// Track our own history stack index so we can correctly reverse
// back/forward regardless of how many entries deep we are.
let stackIndex = (history.state?.__unsaved_guard_index as number) ?? 0;
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);
// Augment pushState to track stack depth
history.pushState = function (state, unused, url) {
stackIndex++;
const augmented = { ...state, __unsaved_guard_index: stackIndex };
return originalPushState(augmented, unused, url);
};
history.replaceState = function (state, unused, url) {
const augmented = { ...state, __unsaved_guard_index: stackIndex };
return originalReplaceState(augmented, unused, url);
};
// Write initial index if not present
if (history.state?.__unsaved_guard_index == null) {
originalReplaceState(
{ ...history.state, __unsaved_guard_index: stackIndex },
"",
location.href,
);
}
const handlePopState = (e: PopStateEvent) => {
if (!hasDirtyRef.current) {
// Not dirty — accept navigation, update our tracked index
stackIndex = (e.state?.__unsaved_guard_index as number) ?? stackIndex;
return;
}
const nextIndex = (e.state?.__unsaved_guard_index as number) ?? 0;
const delta = nextIndex - stackIndex;
if (delta === 0) return;
// Undo the navigation the browser already did
history.go(-delta);
askOrProceed(() => {
// User confirmed — replay the navigation
stackIndex = nextIndex;
history.go(delta);
});
};
window.addEventListener("popstate", handlePopState);
return () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
window.removeEventListener("popstate", handlePopState);
};
}, [askOrProceed]);
// -- Dialog handlers -----------------------------------------------------
const handleConfirm = useCallback(() => {
setShowDialog(false);
const nav = pendingNavigate.current;
pendingNavigate.current = null;
nav?.();
}, []);
const handleCancel = useCallback(() => {
setShowDialog(false);
pendingNavigate.current = null;
}, []);
// -- Render --------------------------------------------------------------
return (
<UnsavedChangesContext.Provider
value={{ register, unregister, hasDirtyChanges, dirtySections, confirmNavigate: askOrProceed }}
>
{children}
<AlertDialog open={showDialog} onOpenChange={(open) => { if (!open) handleCancel(); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes that will be lost. Are you sure you want to leave?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>Stay on page</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Discard changes</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</UnsavedChangesContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
/**
* Register a section's dirty state with the nearest UnsavedChangesProvider.
* Automatically unregisters on unmount.
*
* @example
* useUnsavedChanges("general", isDirty);
*/
export function useUnsavedChanges(sectionId: string, isDirty: boolean) {
const ctx = useContext(UnsavedChangesContext);
if (!ctx) throw new Error("useUnsavedChanges must be used within UnsavedChangesProvider");
const { register, unregister } = ctx;
useEffect(() => {
register(sectionId, isDirty);
}, [sectionId, isDirty, register]);
useEffect(() => {
return () => unregister(sectionId);
}, [sectionId, unregister]);
}
/**
* Access the unsaved-changes context directly (e.g. for dirtySections).
*/
export function useUnsavedChangesContext() {
const ctx = useContext(UnsavedChangesContext);
if (!ctx) throw new Error("useUnsavedChangesContext must be used within UnsavedChangesProvider");
return ctx;
}