feat: add dictionary support for STT boosting in voice agents (#136)

* feat: add dictionary support for voice agents

Also fixes #132

* chore: add keyterms in evals
This commit is contained in:
Abhishek 2026-01-29 11:20:07 +05:30 committed by GitHub
parent e3a1e0bf07
commit db75d90535
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 9666 additions and 53 deletions

View file

@ -6,7 +6,7 @@ import {
Panel,
ReactFlow,
} from "@xyflow/react";
import { BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
import { BookA, BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listToolsApiV1ToolsGet } from '@/client';
@ -20,6 +20,7 @@ import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall, TriggerNode, WebhookNode } from "../../../components/flow/nodes";
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
import { DictionaryDialog } from './components/DictionaryDialog';
import { EmbedDialog } from './components/EmbedDialog';
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
@ -63,6 +64,7 @@ interface RenderWorkflowProps {
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: RenderWorkflowProps) {
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
const [isDictionaryDialogOpen, setIsDictionaryDialogOpen] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
@ -88,7 +90,9 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onNodesChange,
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations
saveWorkflowConfigurations,
dictionary,
saveDictionary
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -238,6 +242,22 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsDictionaryDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<BookA className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Dictionary</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -356,6 +376,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onSave={saveTemplateContextVariables}
/>
<DictionaryDialog
open={isDictionaryDialogOpen}
onOpenChange={setIsDictionaryDialogOpen}
dictionary={dictionary}
onSave={saveDictionary}
/>
<EmbedDialog
open={isEmbedDialogOpen}
onOpenChange={setIsEmbedDialogOpen}

View file

@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
interface DictionaryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dictionary: string;
onSave: (dictionary: string) => Promise<void>;
}
export const DictionaryDialog = ({
open,
onOpenChange,
dictionary,
onSave
}: DictionaryDialogProps) => {
const [dictionaryValue, setDictionaryValue] = useState(dictionary);
// Sync local state with prop when dialog opens
useEffect(() => {
if (open) {
setDictionaryValue(dictionary);
}
}, [open, dictionary]);
const handleSave = async () => {
await onSave(dictionaryValue);
onOpenChange(false);
};
const handleDialogOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen);
if (isOpen) {
setDictionaryValue(dictionary);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Dictionary</DialogTitle>
<DialogDescription>
Add any specific words that you would want the bot to actively listen for. The Voice Agent learns your
unique words and names. Add expected words and phrases, company jargon, named entities, or industry-specific lingo. <br/>
Example: billing department, tretinoin etc. <br/>
(May incur extra cost depending on provider)
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dictionary" className="text-sm font-medium">Words</Label>
<Textarea
id="dictionary"
placeholder="Enter words separated by comma"
value={dictionaryValue}
onChange={(e) => setDictionaryValue(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Dictionary
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,5 +1,5 @@
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -23,6 +23,15 @@ export const TemplateContextVariablesDialog = ({
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
// Sync local state with prop when dialog opens
useEffect(() => {
if (open) {
setContextVars(templateContextVariables);
setNewKey("");
setNewValue("");
}
}, [open, templateContextVariables]);
const handleAddContextVar = () => {
if (newKey && newValue) {
setContextVars(prev => ({ ...prev, [newKey]: newValue }));

View file

@ -20,7 +20,7 @@ import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import logger from '@/lib/logger';
import { getNextNodeId, getRandomId } from "@/lib/utils";
import { WorkflowConfigurations } from "@/types/workflow-configurations";
import { DEFAULT_WORKFLOW_CONFIGURATIONS, WorkflowConfigurations } from "@/types/workflow-configurations";
export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean {
switch (type) {
@ -141,6 +141,8 @@ export const useWorkflowState = ({
setWorkflowValidationErrors,
setTemplateContextVariables,
setWorkflowConfigurations,
setDictionary,
dictionary,
clearValidationErrors,
markNodeAsInvalid,
markEdgeAsInvalid,
@ -174,7 +176,8 @@ export const useWorkflowState = ({
initialNodes,
initialFlow?.edges ?? [],
initialTemplateContextVariables,
initialWorkflowConfigurations
initialWorkflowConfigurations,
initialWorkflowConfigurations?.dictionary ?? ''
);
}, []);
@ -223,6 +226,11 @@ export const useWorkflowState = ({
selected: true, // Mark the new node as selected
};
// Deselect all existing nodes before adding the new one
const currentNodes = rfInstance.current.getNodes();
const deselectedNodes = currentNodes.map(node => ({ ...node, selected: false }));
rfInstance.current.setNodes(deselectedNodes);
// Use addNodes from ReactFlow instance
rfInstance.current.addNodes([newNode]);
setIsAddNodePanelOpen(false);
@ -326,6 +334,19 @@ export const useWorkflowState = ({
await validateWorkflow();
}, [workflowId, workflowName, setIsDirty, user, getAccessToken, validateWorkflow]);
// Set up keyboard shortcut for save (Cmd/Ctrl + S)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
saveWorkflow();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [saveWorkflow]);
const onConnect: OnConnect = useCallback((connection) => {
if (!rfInstance.current) return;
@ -411,6 +432,9 @@ export const useWorkflowState = ({
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => {
if (!user) return;
const accessToken = await getAccessToken();
// Preserve the current dictionary when saving other configurations
const currentDictionary = useWorkflowStore.getState().dictionary;
const configurationsWithDictionary: WorkflowConfigurations = { ...configurations, dictionary: currentDictionary };
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
@ -419,13 +443,13 @@ export const useWorkflowState = ({
body: {
name: newWorkflowName,
workflow_definition: null,
workflow_configurations: configurations as Record<string, unknown>,
workflow_configurations: configurationsWithDictionary as Record<string, unknown>,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setWorkflowConfigurations(configurations);
setWorkflowConfigurations(configurationsWithDictionary);
setWorkflowName(newWorkflowName);
logger.info('Workflow configurations saved successfully');
} catch (error) {
@ -434,6 +458,34 @@ export const useWorkflowState = ({
}
}, [workflowId, user, getAccessToken, setWorkflowConfigurations, setWorkflowName]);
// Save dictionary
const saveDictionary = useCallback(async (newDictionary: string) => {
if (!user) return;
const accessToken = await getAccessToken();
const currentConfigurations = useWorkflowStore.getState().workflowConfigurations ?? DEFAULT_WORKFLOW_CONFIGURATIONS;
const updatedConfigurations: WorkflowConfigurations = { ...currentConfigurations, dictionary: newDictionary };
try {
await updateWorkflowApiV1WorkflowWorkflowIdPut({
path: {
workflow_id: workflowId,
},
body: {
name: workflowName,
workflow_definition: null,
workflow_configurations: updatedConfigurations as Record<string, unknown>,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
setDictionary(newDictionary);
setWorkflowConfigurations(updatedConfigurations);
} catch (error) {
logger.error(`Error saving dictionary: ${error}`);
throw error;
}
}, [workflowId, workflowName, user, getAccessToken, setDictionary, setWorkflowConfigurations]);
// Update rfInstance when it changes
useEffect(() => {
if (rfInstance.current) {
@ -456,6 +508,7 @@ export const useWorkflowState = ({
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
dictionary,
setNodes,
setIsDirty,
setIsAddNodePanelOpen,
@ -468,6 +521,7 @@ export const useWorkflowState = ({
onRun,
saveTemplateContextVariables,
saveWorkflowConfigurations,
saveDictionary,
// Export undo/redo state
undo,
redo,

View file

@ -35,6 +35,7 @@ interface WorkflowState {
// Configuration
templateContextVariables: Record<string, string>;
workflowConfigurations: WorkflowConfigurations | null;
dictionary: string;
// ReactFlow instance reference
rfInstance: ReactFlowInstance<FlowNode, FlowEdge> | null;
@ -48,7 +49,8 @@ interface WorkflowActions {
nodes: FlowNode[],
edges: FlowEdge[],
templateContextVariables?: Record<string, string>,
workflowConfigurations?: WorkflowConfigurations | null
workflowConfigurations?: WorkflowConfigurations | null,
dictionary?: string
) => void;
// History management
@ -74,6 +76,7 @@ interface WorkflowActions {
setWorkflowName: (name: string) => void;
setTemplateContextVariables: (variables: Record<string, string>) => void;
setWorkflowConfigurations: (configurations: WorkflowConfigurations) => void;
setDictionary: (dictionary: string) => void;
// UI state
setIsDirty: (isDirty: boolean) => void;
@ -110,10 +113,11 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
workflowValidationErrors: [],
templateContextVariables: {},
workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS,
dictionary: '',
rfInstance: null,
// Actions
initializeWorkflow: (workflowId, workflowName, nodes, edges, templateContextVariables = {}, workflowConfigurations = DEFAULT_WORKFLOW_CONFIGURATIONS) => {
initializeWorkflow: (workflowId, workflowName, nodes, edges, templateContextVariables = {}, workflowConfigurations = DEFAULT_WORKFLOW_CONFIGURATIONS, dictionary = '') => {
const initialHistory: HistoryState = { nodes, edges, workflowName };
set({
workflowId,
@ -122,6 +126,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
edges,
templateContextVariables,
workflowConfigurations,
dictionary,
isDirty: false,
workflowValidationErrors: [],
history: [initialHistory],
@ -195,18 +200,28 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
setNodes: (nodes, changes) => {
// Determine whether to push to history and set isDirty based on change types
if (changes && changes.length > 0) {
// Check if any changes are user-initiated (not just selections or dimensions)
const hasDirtyChanges = changes.some(change =>
change.type === 'add' ||
change.type === 'remove' ||
(change.type === 'position' && change.dragging)
// Check for add/remove changes (always push to history)
const hasAddRemoveChanges = changes.some(change =>
change.type === 'add' || change.type === 'remove'
);
if (hasDirtyChanges) {
// Check for position changes - only push to history when drag ENDS (dragging: false)
// but still mark as dirty during dragging
const hasDragEndChanges = changes.some(change =>
change.type === 'position' && change.dragging === false
);
const isActiveDragging = changes.some(change =>
change.type === 'position' && change.dragging === true
);
if (hasAddRemoveChanges || hasDragEndChanges) {
get().pushToHistory();
set({ nodes, isDirty: true });
} else if (isActiveDragging) {
// During active dragging, update nodes but don't push to history
set({ nodes, isDirty: true });
} else {
// For selection changes or dimension updates, don't push to history
// For selection changes or dimension updates, don't push to history or set dirty
set({ nodes });
}
} else {
@ -312,6 +327,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ workflowConfigurations });
},
setDictionary: (dictionary) => {
set({ dictionary });
},
setIsDirty: (isDirty) => {
set({ isDirty });
},
@ -375,6 +394,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
workflowValidationErrors: [],
templateContextVariables: {},
workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS,
dictionary: '',
rfInstance: null,
});
},

View file

@ -227,7 +227,7 @@ export default function CustomEdge(props: CustomEdgeProps) {
: isHovered
? 'drop-shadow(0 0 6px rgba(96, 165, 250, 0.4))'
: 'none',
transition: 'all 0.2s ease',
transition: 'stroke 0.2s ease, stroke-width 0.2s ease, filter 0.2s ease',
}}
interactionWidth={20}
/>

View file

@ -15,6 +15,7 @@ export interface WorkflowConfigurations {
ambient_noise_configuration: AmbientNoiseConfiguration;
max_call_duration: number; // Maximum call duration in seconds
max_user_idle_timeout: number; // Maximum user idle time in seconds
dictionary?: string; // Comma-separated words for voice agent to listen for
[key: string]: unknown; // Allow additional properties for future configurations
}
@ -30,5 +31,6 @@ export const DEFAULT_WORKFLOW_CONFIGURATIONS: WorkflowConfigurations = {
volume: 0.3
},
max_call_duration: 600, // 10 minutes
max_user_idle_timeout: 10 // 10 seconds
max_user_idle_timeout: 10, // 10 seconds
dictionary: ''
};