feat: allow recording audio in workflow builder

This commit is contained in:
Abhishek Kumar 2026-03-25 15:01:39 +05:30
parent ac0731a374
commit 2fa4191d9b
22 changed files with 700 additions and 246 deletions

View file

@ -13,6 +13,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VoiceSelector } from "@/components/VoiceSelector";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { useUserConfig } from "@/context/UserConfigContext";
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings";
@ -46,105 +47,6 @@ const TAB_CONFIG: { key: ServiceSegment; label: string }[] = [
{ key: "embeddings", label: "Embedding" },
];
// Display names for language codes (Deepgram + Sarvam)
const LANGUAGE_DISPLAY_NAMES: Record<string, string> = {
"multi": "Multilingual (Auto-detect)",
// Arabic
"ar": "Arabic",
"ar-AE": "Arabic (UAE)",
"ar-SA": "Arabic (Saudi Arabia)",
"ar-QA": "Arabic (Qatar)",
"ar-KW": "Arabic (Kuwait)",
"ar-SY": "Arabic (Syria)",
"ar-LB": "Arabic (Lebanon)",
"ar-PS": "Arabic (Palestine)",
"ar-JO": "Arabic (Jordan)",
"ar-EG": "Arabic (Egypt)",
"ar-SD": "Arabic (Sudan)",
"ar-TD": "Arabic (Chad)",
"ar-MA": "Arabic (Morocco)",
"ar-DZ": "Arabic (Algeria)",
"ar-TN": "Arabic (Tunisia)",
"ar-IQ": "Arabic (Iraq)",
"ar-IR": "Arabic (Iran)",
// Other languages
"be": "Belarusian",
"bn": "Bengali",
"bs": "Bosnian",
"bg": "Bulgarian",
"ca": "Catalan",
"cs": "Czech",
"da": "Danish",
"da-DK": "Danish (Denmark)",
"de": "German",
"de-CH": "German (Switzerland)",
"el": "Greek",
"en": "English",
"en-US": "English (US)",
"en-AU": "English (Australia)",
"en-GB": "English (UK)",
"en-IN": "English (India)",
"en-NZ": "English (New Zealand)",
"es": "Spanish",
"es-419": "Spanish (Latin America)",
"et": "Estonian",
"fa": "Persian",
"fi": "Finnish",
"fr": "French",
"fr-CA": "French (Canada)",
"he": "Hebrew",
"hi": "Hindi",
"hr": "Croatian",
"hu": "Hungarian",
"id": "Indonesian",
"it": "Italian",
"ja": "Japanese",
"kn": "Kannada",
"ko": "Korean",
"ko-KR": "Korean (South Korea)",
"lt": "Lithuanian",
"lv": "Latvian",
"mk": "Macedonian",
"mr": "Marathi",
"ms": "Malay",
"nl": "Dutch",
"nl-BE": "Flemish",
"no": "Norwegian",
"pl": "Polish",
"pt": "Portuguese",
"pt-BR": "Portuguese (Brazil)",
"pt-PT": "Portuguese (Portugal)",
"ro": "Romanian",
"ru": "Russian",
"sk": "Slovak",
"sl": "Slovenian",
"sr": "Serbian",
"sv": "Swedish",
"sv-SE": "Swedish (Sweden)",
"ta": "Tamil",
"te": "Telugu",
"th": "Thai",
"tl": "Tagalog",
"tr": "Turkish",
"uk": "Ukrainian",
"ur": "Urdu",
"vi": "Vietnamese",
"zh-CN": "Chinese (Simplified)",
"zh-TW": "Chinese (Traditional)",
// Sarvam Indian languages
"bn-IN": "Bengali",
"gu-IN": "Gujarati",
"hi-IN": "Hindi",
"kn-IN": "Kannada",
"ml-IN": "Malayalam",
"mr-IN": "Marathi",
"od-IN": "Odia",
"pa-IN": "Punjabi",
"ta-IN": "Tamil",
"te-IN": "Telugu",
"as-IN": "Assamese",
};
// Display names for Sarvam voices
const VOICE_DISPLAY_NAMES: Record<string, string> = {
"anushka": "Anushka (Female)",

View file

@ -215,11 +215,7 @@ export default function CustomEdge(props: CustomEdgeProps) {
const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => {
// Use the workflow store's updateEdge method to properly track history
updateEdge(id, { data: updatedData });
// Save the workflow after updating edge data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
}, [id, updateEdge, saveWorkflow]);
return (

View file

@ -89,10 +89,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
document_uuids: documentUuids.length > 0 ? documentUuids : undefined,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
};
// Reset form state when dialog opens
@ -127,27 +124,23 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
}, [data, open]);
// Handle cleanup of stale document UUIDs
const handleStaleDocuments = useCallback((staleUuids: string[]) => {
const handleStaleDocuments = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.document_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
document_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
// Handle cleanup of stale tool UUIDs
const handleStaleTools = useCallback((staleUuids: string[]) => {
const handleStaleTools = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.tool_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
tool_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
return (

View file

@ -75,10 +75,7 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
add_global_prompt: addGlobalPrompt,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
};
// Reset form state when dialog opens

View file

@ -52,10 +52,7 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
name
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
};
// Reset form state when dialog opens

View file

@ -66,9 +66,7 @@ export const QANode = memo(({ data, selected, id }: QANodeProps) => {
qa_sample_rate: qaSampleRate,
});
setOpen(false);
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
};
const resetFormState = () => {

View file

@ -104,10 +104,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
document_uuids: documentUuids.length > 0 ? documentUuids : undefined,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
};
// Reset form state when dialog opens
@ -148,27 +145,23 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
}, [data, open]);
// Handle cleanup of stale document UUIDs
const handleStaleDocuments = useCallback((staleUuids: string[]) => {
const handleStaleDocuments = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.document_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
document_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
// Handle cleanup of stale tool UUIDs
const handleStaleTools = useCallback((staleUuids: string[]) => {
const handleStaleTools = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.tool_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
tool_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
return (

View file

@ -61,10 +61,7 @@ export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
trigger_path: triggerPath,
});
setOpen(false);
// Save the workflow after updating node data
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
};
// Reset form state when dialog opens

View file

@ -86,9 +86,7 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
payload_template: validation.parsed as Record<string, unknown>,
});
setOpen(false);
setTimeout(async () => {
await saveWorkflow();
}, 100);
await saveWorkflow();
};
const handleOpenChange = (newOpen: boolean) => {

View file

@ -71,12 +71,13 @@ export const NodeEditDialog = ({
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
e.stopImmediatePropagation();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, handleSave]);
return (

View file

@ -1,22 +1,114 @@
'use client';
import { PlusIcon } from 'lucide-react';
import { Bot, ChevronDown, LayoutTemplate, PlusIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { createWorkflowApiV1WorkflowCreateDefinitionPost } from '@/client/sdk.gen';
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { getRandomId } from '@/lib/utils';
const BLANK_WORKFLOW_DEFINITION = {
nodes: [
{
id: "1",
type: "startCall",
position: { x: 175, y: 60 },
data: {
prompt: "# Goal\nYou are a helpful agent who is handing a conversation over voice with a human. This is a voice conversation, so transcripts can be error prone.\n\n## Rules\n- Language: UK English but does not have to be correct english\n- Keep responses short and 2-3 sentences max\n- If you have to repeat something that you said in your previous two turns, then rephrase a bit while keeping the same meaning. Never repeat the exact same words as in your previous 2 responses.\n\n## Speech Handling\n- There could be multiple transcription errors. \n- Accept variations: yes/yeah/yep/aye, no/nah/nope\n- If user says \"sorry?\" or \"pardon me\" or \"can you repeat\" or \"what?\", they might not have heard you- so just repeat what you just said.\n\n### Flow\nStart by saying \"Hi\". Be polite and courteous. ",
name: "start call",
allow_interrupt: false,
invalid: false,
validationMessage: null,
is_static: false,
add_global_prompt: false,
wait_for_user_response: false,
detect_voicemail: true,
delayed_start: false,
is_start: true,
selected_through_edge: false,
hovered_through_edge: false,
extraction_enabled: false,
selected: false,
dragging: false,
},
},
],
edges: [],
viewport: { x: 808, y: 269, zoom: 0.75 },
};
export function CreateWorkflowButton() {
const router = useRouter();
const handleClick = () => {
const { user, getAccessToken } = useAuth();
const [isCreating, setIsCreating] = useState(false);
const handleAgentBuilder = () => {
router.push('/workflow/create');
};
const handleBlankCanvas = async () => {
if (isCreating || !user) return;
setIsCreating(true);
try {
const accessToken = await getAccessToken();
const name = `Workflow-${getRandomId()}`;
const response = await createWorkflowApiV1WorkflowCreateDefinitionPost({
body: {
name,
workflow_definition: BLANK_WORKFLOW_DEFINITION as unknown as { [key: string]: unknown },
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.id) {
router.push(`/workflow/${response.data.id}`);
}
} catch (err) {
logger.error(`Error creating blank workflow: ${err}`);
toast.error('Failed to create workflow');
} finally {
setIsCreating(false);
}
};
return (
<Button
onClick={handleClick}
>
<PlusIcon className="w-4 h-4" />
Create Agent
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button disabled={isCreating}>
<PlusIcon className="w-4 h-4" />
{isCreating ? 'Creating...' : 'Create Agent'}
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleAgentBuilder} className="cursor-pointer">
<Bot className="w-4 h-4 mr-2" />
<div>
<div className="font-medium">Use Agent Builder</div>
<div className="text-xs text-muted-foreground">AI generates a workflow from your description</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleBlankCanvas} disabled={isCreating} className="cursor-pointer">
<LayoutTemplate className="w-4 h-4 mr-2" />
<div>
<div className="font-medium">Blank Canvas</div>
<div className="text-xs text-muted-foreground">Start from scratch with an empty workflow</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}