mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-28 08:49:42 +02:00
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:
parent
3f19a16e7f
commit
7c245051d2
54 changed files with 3575 additions and 640 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -415,7 +415,6 @@ export default function NewCampaignPage() {
|
|||
/>
|
||||
) : (
|
||||
<CsvUploadSelector
|
||||
accessToken={userAccessToken}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
selectedFileName={selectedFileName}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
323
ui/src/app/recordings/RecordingsList.tsx
Normal file
323
ui/src/app/recordings/RecordingsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
465
ui/src/app/recordings/RecordingsUploadDialog.tsx
Normal file
465
ui/src/app/recordings/RecordingsUploadDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
ui/src/app/recordings/page.tsx
Normal file
77
ui/src/app/recordings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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., "Let me look that up for you")
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue