mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
feat: allow uploading recording as part of node transition
This commit is contained in:
parent
bb5f56bfb7
commit
65c76ca7ff
36 changed files with 2255 additions and 201 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}
|
||||
/>
|
||||
|
|
|
|||
382
ui/src/app/recordings/RecordingsList.tsx
Normal file
382
ui/src/app/recordings/RecordingsList.tsx
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
"use client";
|
||||
|
||||
import { AudioLines, Check, Pause, Pencil, Play, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete,
|
||||
getWorkflowsSummaryApiV1WorkflowSummaryGet,
|
||||
listRecordingsApiV1WorkflowRecordingsGet,
|
||||
updateRecordingApiV1WorkflowRecordingsIdPatch,
|
||||
} from "@/client/sdk.gen";
|
||||
import type { RecordingResponseSchema, WorkflowSummaryResponse } from "@/client/types.gen";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
const ALL_VALUE = "__all__";
|
||||
|
||||
export default function RecordingsList() {
|
||||
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string>(ALL_VALUE);
|
||||
|
||||
// Inline edit state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
||||
const { playingId, toggle: togglePlayback, stop: stopPlayback } = useAudioPlayback();
|
||||
const hasFetchedWorkflows = useRef(false);
|
||||
|
||||
const workflowMap = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const w of workflows) {
|
||||
map.set(w.id, w.name);
|
||||
}
|
||||
return map;
|
||||
}, [workflows]);
|
||||
|
||||
const fetchWorkflows = useCallback(async () => {
|
||||
try {
|
||||
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet();
|
||||
if (response.data) {
|
||||
setWorkflows(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Error fetching workflows:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRecordings = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await listRecordingsApiV1WorkflowRecordingsGet({
|
||||
query: {
|
||||
workflow_id: selectedWorkflow !== ALL_VALUE ? Number(selectedWorkflow) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
throw new Error("Failed to fetch recordings");
|
||||
}
|
||||
|
||||
setRecordings(response.data.recordings);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch recordings");
|
||||
logger.error("Error fetching recordings:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedWorkflow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFetchedWorkflows.current) {
|
||||
hasFetchedWorkflows.current = true;
|
||||
fetchWorkflows();
|
||||
}
|
||||
}, [fetchWorkflows]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecordings();
|
||||
}, [fetchRecordings]);
|
||||
|
||||
const handleDelete = async (recordingId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this recording?")) return;
|
||||
|
||||
try {
|
||||
const response = await deleteRecordingApiV1WorkflowRecordingsRecordingIdDelete({
|
||||
path: { recording_id: recordingId },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error("Failed to delete recording");
|
||||
}
|
||||
|
||||
toast.success("Recording deleted");
|
||||
fetchRecordings();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete recording");
|
||||
logger.error("Error deleting recording:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = async (rec: RecordingResponseSchema) => {
|
||||
try {
|
||||
await togglePlayback(rec.recording_id, rec.storage_key, rec.storage_backend);
|
||||
} catch {
|
||||
toast.error("Failed to play recording");
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (rec: RecordingResponseSchema) => {
|
||||
setEditingId(rec.recording_id);
|
||||
setEditValue(rec.recording_id);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const saveRecordingId = async (rec: RecordingResponseSchema) => {
|
||||
const newId = editValue.trim();
|
||||
if (!newId) {
|
||||
toast.error("Recording ID cannot be empty");
|
||||
return;
|
||||
}
|
||||
if (newId === rec.recording_id) {
|
||||
cancelEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateRecordingApiV1WorkflowRecordingsIdPatch({
|
||||
path: { id: rec.id },
|
||||
body: { recording_id: newId },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
const errData = response.error as { detail?: string };
|
||||
throw new Error(errData?.detail || "Failed to update recording ID");
|
||||
}
|
||||
|
||||
toast.success(`Recording ID updated to "${newId}"`);
|
||||
cancelEditing();
|
||||
fetchRecordings();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update recording ID");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
const filteredRecordings = recordings.filter((rec) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
const filename = (rec.metadata?.original_filename as string) || "";
|
||||
return (
|
||||
filename.toLowerCase().includes(q) ||
|
||||
rec.transcript.toLowerCase().includes(q) ||
|
||||
rec.recording_id.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading && recordings.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter */}
|
||||
<div className="max-w-xs">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Voice Agent</label>
|
||||
<Select value={selectedWorkflow} onValueChange={setSelectedWorkflow}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="All agents" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All agents</SelectItem>
|
||||
{workflows.map((w) => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Search and Refresh */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by filename, transcript, or ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => { stopPlayback(); fetchRecordings(); }}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredRecordings.length} recording{filteredRecordings.length !== 1 ? "s" : ""}
|
||||
{searchQuery && ` matching "${searchQuery}"`}
|
||||
</div>
|
||||
|
||||
{/* Recordings List */}
|
||||
{filteredRecordings.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<AudioLines className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No recordings match your search"
|
||||
: "No recordings found for the selected filters"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredRecordings.map((rec) => {
|
||||
const filename = (rec.metadata?.original_filename as string) || "";
|
||||
const workflowName = workflowMap.get(rec.workflow_id);
|
||||
const isEditing = editingId === rec.recording_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rec.recording_id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<AudioLines className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Recording ID (editable) */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRecordingId(rec);
|
||||
if (e.key === "Escape") cancelEditing();
|
||||
}}
|
||||
className="h-7 text-sm font-mono w-48"
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => saveRecordingId(rec)}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={cancelEditing}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded truncate max-w-[250px]">
|
||||
{rec.recording_id}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => startEditing(rec)}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{workflowName && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{workflowName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Filename */}
|
||||
{filename && (
|
||||
<p className="text-xs text-muted-foreground mb-0.5 truncate max-w-[300px]">
|
||||
{filename}
|
||||
</p>
|
||||
)}
|
||||
{/* Transcript */}
|
||||
<p className="text-sm text-muted-foreground line-clamp-1 mb-1">
|
||||
{rec.transcript}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
||||
<span>{rec.tts_provider}</span>
|
||||
<span>{rec.tts_model}</span>
|
||||
<span className="truncate max-w-[150px]">{rec.tts_voice_id}</span>
|
||||
<span>{formatDate(rec.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlay(rec)}
|
||||
>
|
||||
{playingId === rec.recording_id ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rec.recording_id)}
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
ui/src/app/recordings/page.tsx
Normal file
53
ui/src/app/recordings/page.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import RecordingsList from "./RecordingsList";
|
||||
|
||||
export default function RecordingsPage() {
|
||||
const { user, redirectToLogin, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Recordings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View all audio recordings across your voice agents. Filter by agent, provider, model, or voice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Recordings</CardTitle>
|
||||
<CardDescription>
|
||||
Audio recordings scoped to your organization
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecordingsList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export default function ToolDetailPage() {
|
|||
const [endCallMessageType, setEndCallMessageType] = useState<EndCallMessageType>("none");
|
||||
const [endCallReason, setEndCallReason] = useState(false);
|
||||
const [endCallReasonDescription, setEndCallReasonDescription] = useState("");
|
||||
const [audioRecordingId, setAudioRecordingId] = useState("");
|
||||
|
||||
const handleEndCallReasonChange = (enabled: boolean) => {
|
||||
setEndCallReason(enabled);
|
||||
|
|
@ -87,6 +88,11 @@ export default function ToolDetailPage() {
|
|||
const [transferDestination, setTransferDestination] = useState("");
|
||||
const [transferMessageType, setTransferMessageType] = useState<EndCallMessageType>("none");
|
||||
const [transferTimeout, setTransferTimeout] = useState(30);
|
||||
const [transferAudioRecordingId, setTransferAudioRecordingId] = useState("");
|
||||
|
||||
// HTTP API form state - custom message type
|
||||
const [customMessageType, setCustomMessageType] = useState<'text' | 'audio'>('text');
|
||||
const [customMessageRecordingId, setCustomMessageRecordingId] = useState("");
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
|
|
@ -132,11 +138,14 @@ export default function ToolDetailPage() {
|
|||
if (config) {
|
||||
setEndCallMessageType(config.messageType || "none");
|
||||
setCustomMessage(config.customMessage || "");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setAudioRecordingId((config as any).audioRecordingId || "");
|
||||
setEndCallReason(config.endCallReason ?? false);
|
||||
setEndCallReasonDescription(config.endCallReasonDescription || "");
|
||||
} else {
|
||||
setEndCallMessageType("none");
|
||||
setCustomMessage("");
|
||||
setAudioRecordingId("");
|
||||
setEndCallReason(false);
|
||||
setEndCallReasonDescription("");
|
||||
}
|
||||
|
|
@ -147,11 +156,14 @@ export default function ToolDetailPage() {
|
|||
setTransferDestination(config.destination || "");
|
||||
setTransferMessageType(config.messageType || "none");
|
||||
setCustomMessage(config.customMessage || "");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setTransferAudioRecordingId((config as any).audioRecordingId || "");
|
||||
setTransferTimeout(config.timeout ?? 30);
|
||||
} else {
|
||||
setTransferDestination("");
|
||||
setTransferMessageType("none");
|
||||
setCustomMessage("");
|
||||
setTransferAudioRecordingId("");
|
||||
setTransferTimeout(30);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -163,6 +175,10 @@ export default function ToolDetailPage() {
|
|||
setCredentialUuid(config.credential_uuid || "");
|
||||
setTimeoutMs(config.timeout_ms || 5000);
|
||||
setCustomMessage(config.customMessage || "");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setCustomMessageType((config as any).customMessageType || "text");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setCustomMessageRecordingId((config as any).customMessageRecordingId || "");
|
||||
|
||||
// Convert headers object to array
|
||||
if (config.headers) {
|
||||
|
|
@ -259,6 +275,7 @@ export default function ToolDetailPage() {
|
|||
config: {
|
||||
messageType: endCallMessageType,
|
||||
customMessage: endCallMessageType === "custom" ? customMessage : undefined,
|
||||
audioRecordingId: endCallMessageType === "audio" ? audioRecordingId || undefined : undefined,
|
||||
endCallReason,
|
||||
endCallReasonDescription: endCallReason ? endCallReasonDescription || undefined : undefined,
|
||||
},
|
||||
|
|
@ -276,6 +293,7 @@ export default function ToolDetailPage() {
|
|||
destination: transferDestination,
|
||||
messageType: transferMessageType,
|
||||
customMessage: transferMessageType === "custom" ? customMessage : undefined,
|
||||
audioRecordingId: transferMessageType === "audio" ? transferAudioRecordingId || undefined : undefined,
|
||||
timeout: transferTimeout,
|
||||
},
|
||||
},
|
||||
|
|
@ -306,7 +324,9 @@ export default function ToolDetailPage() {
|
|||
parameters:
|
||||
validParameters.length > 0 ? validParameters : undefined,
|
||||
timeout_ms: timeoutMs,
|
||||
customMessage: customMessage || undefined,
|
||||
customMessage: customMessageType === 'text' ? (customMessage || undefined) : undefined,
|
||||
customMessageType,
|
||||
customMessageRecordingId: customMessageType === 'audio' ? (customMessageRecordingId || undefined) : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -490,6 +510,8 @@ const data = await response.json();`;
|
|||
onMessageTypeChange={setEndCallMessageType}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
audioRecordingId={audioRecordingId}
|
||||
onAudioRecordingIdChange={setAudioRecordingId}
|
||||
endCallReason={endCallReason}
|
||||
onEndCallReasonChange={handleEndCallReasonChange}
|
||||
endCallReasonDescription={endCallReasonDescription}
|
||||
|
|
@ -507,6 +529,8 @@ const data = await response.json();`;
|
|||
onMessageTypeChange={setTransferMessageType}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
audioRecordingId={transferAudioRecordingId}
|
||||
onAudioRecordingIdChange={setTransferAudioRecordingId}
|
||||
timeout={transferTimeout}
|
||||
onTimeoutChange={setTransferTimeout}
|
||||
/>
|
||||
|
|
@ -530,6 +554,10 @@ const data = await response.json();`;
|
|||
onTimeoutMsChange={setTimeoutMs}
|
||||
customMessage={customMessage}
|
||||
onCustomMessageChange={setCustomMessage}
|
||||
customMessageType={customMessageType}
|
||||
onCustomMessageTypeChange={setCustomMessageType}
|
||||
customMessageRecordingId={customMessageRecordingId}
|
||||
onCustomMessageRecordingIdChange={setCustomMessageRecordingId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
|
||||
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import logger from "@/lib/logger";
|
||||
|
|
@ -113,6 +114,21 @@ function GeneralSection({
|
|||
const ambientFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { playingId, toggle: togglePlayback } = useAudioPlayback();
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
const initAmbient = workflowConfigurations.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG;
|
||||
return (
|
||||
name !== workflowName ||
|
||||
JSON.stringify(ambientNoiseConfig) !== JSON.stringify(initAmbient) ||
|
||||
maxCallDuration !== (workflowConfigurations.max_call_duration || 600) ||
|
||||
maxUserIdleTimeout !== (workflowConfigurations.max_user_idle_timeout || 10) ||
|
||||
smartTurnStopSecs !== (workflowConfigurations.smart_turn_stop_secs || 2) ||
|
||||
turnStopStrategy !== (workflowConfigurations.turn_stop_strategy || "transcription") ||
|
||||
contextCompactionEnabled !== (workflowConfigurations.context_compaction_enabled ?? false)
|
||||
);
|
||||
}, [name, workflowName, ambientNoiseConfig, maxCallDuration, maxUserIdleTimeout, smartTurnStopSecs, turnStopStrategy, contextCompactionEnabled, workflowConfigurations]);
|
||||
|
||||
useUnsavedChanges("general", isDirty);
|
||||
|
||||
const handleAmbientFileUpload = async (file: File) => {
|
||||
if (file.size > MAX_AMBIENT_NOISE_FILE_SIZE) {
|
||||
setAudioUploadError(`File too large (${(file.size / (1024 * 1024)).toFixed(1)}MB). Maximum is 10MB.`);
|
||||
|
|
@ -463,8 +479,9 @@ function GeneralSection({
|
|||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<CardFooter className="justify-end gap-3 border-t pt-6">
|
||||
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
|
||||
{isSaving ? "Saving..." : "Save General Settings"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
@ -488,6 +505,13 @@ function TemplateVariablesSection({
|
|||
const [newValue, setNewValue] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
const pendingVars = newKey && newValue ? { ...contextVars, [newKey]: newValue } : contextVars;
|
||||
return JSON.stringify(pendingVars) !== JSON.stringify(templateContextVariables);
|
||||
}, [contextVars, newKey, newValue, templateContextVariables]);
|
||||
|
||||
useUnsavedChanges("variables", isDirty);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (newKey && newValue) {
|
||||
setContextVars((prev) => ({ ...prev, [newKey]: newValue }));
|
||||
|
|
@ -578,8 +602,9 @@ function TemplateVariablesSection({
|
|||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<CardFooter className="justify-end gap-3 border-t pt-6">
|
||||
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
|
||||
{isSaving ? "Saving..." : "Save Variables"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
@ -601,6 +626,10 @@ function DictionarySection({
|
|||
const [dictionaryValue, setDictionaryValue] = useState(dictionary);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isDirty = dictionaryValue !== dictionary;
|
||||
|
||||
useUnsavedChanges("dictionary", isDirty);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
|
|
@ -633,8 +662,9 @@ function DictionarySection({
|
|||
className="resize-none"
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<CardFooter className="justify-end gap-3 border-t pt-6">
|
||||
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
|
||||
{isSaving ? "Saving..." : "Save Dictionary"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
@ -669,6 +699,24 @@ function VoicemailSection({
|
|||
const [longSpeechTimeout, setLongSpeechTimeout] = useState(getConfig().long_speech_timeout);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
const init = {
|
||||
...DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
|
||||
...workflowConfigurations.voicemail_detection,
|
||||
};
|
||||
return (
|
||||
enabled !== init.enabled ||
|
||||
useWorkflowLlm !== init.use_workflow_llm ||
|
||||
provider !== (init.provider || "openai") ||
|
||||
model !== (init.model || "gpt-4.1") ||
|
||||
apiKey !== (init.api_key || "") ||
|
||||
systemPrompt !== (init.system_prompt || DEFAULT_VOICEMAIL_SYSTEM_PROMPT) ||
|
||||
longSpeechTimeout !== init.long_speech_timeout
|
||||
);
|
||||
}, [enabled, useWorkflowLlm, provider, model, apiKey, systemPrompt, longSpeechTimeout, workflowConfigurations]);
|
||||
|
||||
useUnsavedChanges("voicemail", isDirty);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
|
|
@ -772,8 +820,9 @@ function VoicemailSection({
|
|||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<CardFooter className="justify-end gap-3 border-t pt-6">
|
||||
{isDirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
<Button onClick={handleSave} disabled={isSaving || !isDirty}>
|
||||
{isSaving ? "Saving..." : "Save Voicemail Settings"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
@ -848,8 +897,23 @@ function WorkflowSettingsContent({
|
|||
}: {
|
||||
workflow: WorkflowResponse;
|
||||
user: { id: string; email?: string };
|
||||
}) {
|
||||
return (
|
||||
<UnsavedChangesProvider>
|
||||
<WorkflowSettingsInner workflow={workflow} user={user} />
|
||||
</UnsavedChangesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowSettingsInner({
|
||||
workflow,
|
||||
user,
|
||||
}: {
|
||||
workflow: WorkflowResponse;
|
||||
user: { id: string; email?: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { dirtySections, confirmNavigate } = useUnsavedChangesContext();
|
||||
|
||||
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
|
|
@ -921,7 +985,7 @@ function WorkflowSettingsContent({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/workflow/${workflowId}`)}
|
||||
onClick={() => confirmNavigate(() => router.push(`/workflow/${workflowId}`))}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -1047,13 +1111,16 @@ function WorkflowSettingsContent({
|
|||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`block rounded-md px-2 py-1 text-sm transition-colors hover:text-foreground ${
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors hover:text-foreground ${
|
||||
activeSection === item.id
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{dirtySections.has(item.id) && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-orange-500" />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -2230,6 +2230,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
|
||||
*/
|
||||
recording_id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* RecordingUploadResponseSchema
|
||||
*
|
||||
|
|
@ -8885,13 +8899,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 +9031,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?: {
|
||||
|
|
|
|||
97
ui/src/components/flow/TextOrAudioInput.tsx
Normal file
97
ui/src/components/flow/TextOrAudioInput.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Select a pre-recorded audio file to play.
|
||||
</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a recording" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordings.length === 0 ? (
|
||||
<SelectItem value="__empty__" disabled>
|
||||
No recordings available
|
||||
</SelectItem>
|
||||
) : (
|
||||
recordings.map((r) => (
|
||||
<SelectItem key={r.recording_id} value={r.recording_id}>
|
||||
<span className="truncate">
|
||||
{(r.metadata?.original_filename as string) || r.recording_id}
|
||||
</span>
|
||||
{r.transcript && (
|
||||
<span className="text-xs text-muted-foreground ml-2 truncate">
|
||||
— {r.transcript}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
269
ui/src/context/UnsavedChangesContext.tsx
Normal file
269
ui/src/context/UnsavedChangesContext.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue