2026-02-25 13:53:30 +05:30
import { NodeProps , NodeToolbar , Position } from "@xyflow/react" ;
import { ChevronDown , ChevronRight , Circle , ClipboardCheck , Edit , Trash2Icon } from "lucide-react" ;
import { memo , useEffect , useMemo , useState } from "react" ;
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext" ;
import { FlowNodeData } from "@/components/flow/types" ;
import { LLMConfigSelector } from "@/components/LLMConfigSelector" ;
import { Button } from "@/components/ui/button" ;
import { Input } from "@/components/ui/input" ;
import { Label } from "@/components/ui/label" ;
import { Switch } from "@/components/ui/switch" ;
import { Textarea } from "@/components/ui/textarea" ;
2026-03-09 11:39:25 +05:30
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation" ;
2026-02-25 13:53:30 +05:30
import { NodeContent } from "./common/NodeContent" ;
import { NodeEditDialog } from "./common/NodeEditDialog" ;
import { useNodeHandlers } from "./common/useNodeHandlers" ;
interface QANodeProps extends NodeProps {
data : FlowNodeData ;
}
export const QANode = memo ( ( { data , selected , id } : QANodeProps ) = > {
const { open , setOpen , handleSaveNodeData , handleDeleteNode } = useNodeHandlers ( { id } ) ;
const { saveWorkflow } = useWorkflow ( ) ;
// Form state
const [ name , setName ] = useState ( data . name || "QA Analysis" ) ;
const [ qaEnabled , setQaEnabled ] = useState ( data . qa_enabled ? ? true ) ;
const [ useWorkflowLlm , setUseWorkflowLlm ] = useState ( data . qa_use_workflow_llm ? ? true ) ;
const [ qaProvider , setQaProvider ] = useState ( data . qa_provider || "openai" ) ;
const [ qaModel , setQaModel ] = useState ( data . qa_model || "gpt-4.1" ) ;
const [ qaApiKey , setQaApiKey ] = useState ( data . qa_api_key || "" ) ;
const [ qaSystemPrompt , setQaSystemPrompt ] = useState ( data . qa_system_prompt || "" ) ;
const [ minCallDuration , setMinCallDuration ] = useState ( data . qa_min_call_duration ? ? 15 ) ;
const [ qaVoicemailCalls , setQaVoicemailCalls ] = useState ( data . qa_voicemail_calls ? ? false ) ;
const [ qaSampleRate , setQaSampleRate ] = useState ( data . qa_sample_rate ? ? 100 ) ;
const isDirty = useMemo ( ( ) = > {
return (
name !== ( data . name || "QA Analysis" ) ||
qaEnabled !== ( data . qa_enabled ? ? true ) ||
useWorkflowLlm !== ( data . qa_use_workflow_llm ? ? true ) ||
qaProvider !== ( data . qa_provider || "openai" ) ||
qaModel !== ( data . qa_model || "gpt-4.1" ) ||
qaApiKey !== ( data . qa_api_key || "" ) ||
qaSystemPrompt !== ( data . qa_system_prompt || "" ) ||
minCallDuration !== ( data . qa_min_call_duration ? ? 15 ) ||
qaVoicemailCalls !== ( data . qa_voicemail_calls ? ? false ) ||
qaSampleRate !== ( data . qa_sample_rate ? ? 100 )
) ;
} , [ name , qaEnabled , useWorkflowLlm , qaProvider , qaModel , qaApiKey , qaSystemPrompt , minCallDuration , qaVoicemailCalls , qaSampleRate , data ] ) ;
const handleSave = async ( ) = > {
handleSaveNodeData ( {
. . . data ,
name ,
qa_enabled : qaEnabled ,
qa_use_workflow_llm : useWorkflowLlm ,
qa_provider : qaProvider ,
qa_model : qaModel ,
qa_api_key : qaApiKey ,
qa_system_prompt : qaSystemPrompt ,
qa_min_call_duration : minCallDuration ,
qa_voicemail_calls : qaVoicemailCalls ,
qa_sample_rate : qaSampleRate ,
} ) ;
setOpen ( false ) ;
2026-03-25 15:01:39 +05:30
await saveWorkflow ( ) ;
2026-02-25 13:53:30 +05:30
} ;
const resetFormState = ( ) = > {
setName ( data . name || "QA Analysis" ) ;
setQaEnabled ( data . qa_enabled ? ? true ) ;
setUseWorkflowLlm ( data . qa_use_workflow_llm ? ? true ) ;
setQaProvider ( data . qa_provider || "openai" ) ;
setQaModel ( data . qa_model || "gpt-4.1" ) ;
setQaApiKey ( data . qa_api_key || "" ) ;
setQaSystemPrompt ( data . qa_system_prompt || "" ) ;
setMinCallDuration ( data . qa_min_call_duration ? ? 15 ) ;
setQaVoicemailCalls ( data . qa_voicemail_calls ? ? false ) ;
setQaSampleRate ( data . qa_sample_rate ? ? 100 ) ;
} ;
const handleOpenChange = ( newOpen : boolean ) = > {
if ( newOpen ) {
resetFormState ( ) ;
}
setOpen ( newOpen ) ;
} ;
useEffect ( ( ) = > {
if ( open ) {
resetFormState ( ) ;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ data , open ] ) ;
return (
< >
< NodeContent
selected = { selected }
invalid = { data . invalid }
selected_through_edge = { data . selected_through_edge }
hovered_through_edge = { data . hovered_through_edge }
title = { data . name || "QA Analysis" }
icon = { < ClipboardCheck / > }
nodeType = "qa"
onDoubleClick = { ( ) = > handleOpenChange ( true ) }
nodeId = { id }
>
< div className = "space-y-2" >
< div className = "flex items-center gap-2" >
< span className = "text-xs font-mono bg-muted px-1.5 py-0.5 rounded" >
{ data . qa_use_workflow_llm !== false
? "Workflow LLM"
: ` ${ data . qa_provider || "openai" } / ${ data . qa_model || "gpt-4.1" } ` }
< / span >
< / div >
< div className = "flex items-center gap-1.5" >
< Circle
className = { ` h-2 w-2 ${ data . qa_enabled !== false ? "fill-green-500 text-green-500" : "fill-gray-400 text-gray-400" } ` }
/ >
< span className = "text-xs text-muted-foreground" >
{ data . qa_enabled !== false ? "Enabled" : "Disabled" }
< / span >
< / div >
< / div >
< / NodeContent >
< NodeToolbar isVisible = { selected } position = { Position . Right } >
< div className = "flex flex-col gap-1" >
< Button onClick = { ( ) = > handleOpenChange ( true ) } variant = "outline" size = "icon" >
< Edit / >
< / Button >
< Button onClick = { handleDeleteNode } variant = "outline" size = "icon" >
< Trash2Icon / >
< / Button >
< / div >
< / NodeToolbar >
< NodeEditDialog
open = { open }
onOpenChange = { handleOpenChange }
nodeData = { data }
title = "Edit QA Analysis"
onSave = { handleSave }
isDirty = { isDirty }
2026-03-09 11:39:25 +05:30
documentationUrl = { NODE_DOCUMENTATION_URLS . qaAnalysis }
2026-02-25 13:53:30 +05:30
>
{ open && (
< QANodeEditForm
name = { name }
setName = { setName }
qaEnabled = { qaEnabled }
setQaEnabled = { setQaEnabled }
useWorkflowLlm = { useWorkflowLlm }
setUseWorkflowLlm = { setUseWorkflowLlm }
qaProvider = { qaProvider }
setQaProvider = { setQaProvider }
qaModel = { qaModel }
setQaModel = { setQaModel }
qaApiKey = { qaApiKey }
setQaApiKey = { setQaApiKey }
qaSystemPrompt = { qaSystemPrompt }
setQaSystemPrompt = { setQaSystemPrompt }
minCallDuration = { minCallDuration }
setMinCallDuration = { setMinCallDuration }
qaVoicemailCalls = { qaVoicemailCalls }
setQaVoicemailCalls = { setQaVoicemailCalls }
qaSampleRate = { qaSampleRate }
setQaSampleRate = { setQaSampleRate }
/ >
) }
< / NodeEditDialog >
< / >
) ;
} ) ;
interface QANodeEditFormProps {
name : string ;
setName : ( value : string ) = > void ;
qaEnabled : boolean ;
setQaEnabled : ( value : boolean ) = > void ;
useWorkflowLlm : boolean ;
setUseWorkflowLlm : ( value : boolean ) = > void ;
qaProvider : string ;
setQaProvider : ( value : string ) = > void ;
qaModel : string ;
setQaModel : ( value : string ) = > void ;
qaApiKey : string ;
setQaApiKey : ( value : string ) = > void ;
qaSystemPrompt : string ;
setQaSystemPrompt : ( value : string ) = > void ;
minCallDuration : number ;
setMinCallDuration : ( value : number ) = > void ;
qaVoicemailCalls : boolean ;
setQaVoicemailCalls : ( value : boolean ) = > void ;
qaSampleRate : number ;
setQaSampleRate : ( value : number ) = > void ;
}
const QANodeEditForm = ( {
name ,
setName ,
qaEnabled ,
setQaEnabled ,
useWorkflowLlm ,
setUseWorkflowLlm ,
qaProvider ,
setQaProvider ,
qaModel ,
setQaModel ,
qaApiKey ,
setQaApiKey ,
qaSystemPrompt ,
setQaSystemPrompt ,
minCallDuration ,
setMinCallDuration ,
qaVoicemailCalls ,
setQaVoicemailCalls ,
qaSampleRate ,
setQaSampleRate ,
} : QANodeEditFormProps ) = > {
const [ advancedOpen , setAdvancedOpen ] = useState ( false ) ;
return (
< div className = "space-y-4" >
< div className = "grid gap-2" >
< Label > Name < / Label >
< Label className = "text-xs text-muted-foreground" >
A display name for this QA analysis node .
< / Label >
< Input value = { name } onChange = { ( e ) = > setName ( e . target . value ) } / >
< / div >
< div className = "flex items-center space-x-2 p-2 border rounded-md bg-muted/20" >
< Switch id = "qa-enabled" checked = { qaEnabled } onCheckedChange = { setQaEnabled } / >
< Label htmlFor = "qa-enabled" > Enabled < / Label >
< Label className = "text-xs text-muted-foreground ml-2" >
Whether this QA analysis runs after each call .
< / Label >
< / div >
< div className = "flex items-center space-x-2 p-2 border rounded-md bg-muted/20" >
< Switch
id = "use-workflow-llm"
checked = { useWorkflowLlm }
onCheckedChange = { setUseWorkflowLlm }
/ >
< Label htmlFor = "use-workflow-llm" > Use Workflow LLM < / Label >
< Label className = "text-xs text-muted-foreground ml-2" >
Use the LLM configured in your account settings .
< / Label >
< / div >
{ ! useWorkflowLlm && (
< LLMConfigSelector
provider = { qaProvider }
onProviderChange = { setQaProvider }
model = { qaModel }
onModelChange = { setQaModel }
apiKey = { qaApiKey }
onApiKeyChange = { setQaApiKey }
/ >
) }
< div className = "grid gap-2" >
< Label > System Prompt < / Label >
< Label className = "text-xs text-muted-foreground" >
2026-02-25 17:17:48 +05:30
The prompt sent to the LLM for per - node QA analysis . Available placeholders : { ' ' }
{ '{{node_summary}}' } ( purpose of the current node ) , { '{{previous_conversation_summary}}' } { ' ' }
( summary of conversation before this node ) , { '{{transcript}}' } ( this node & apos ; s
conversation ) , { '{{metrics}}' } ( call metrics for this node ) .
2026-02-25 13:53:30 +05:30
< / Label >
< Textarea
value = { qaSystemPrompt }
onChange = { ( e ) = > setQaSystemPrompt ( e . target . value ) }
className = "min-h-[300px] font-mono text-xs"
2026-02-25 17:17:48 +05:30
placeholder = { ` You are a QA analyst evaluating a specific segment of a voice AI conversation. \ n \ n## Node Purpose \ n{{node_summary}} \ n \ n## Previous Conversation Context \ n{{previous_conversation_summary}} \ n \ n## Call Metrics \ n{{metrics}} \ n \ nEvaluate the transcript and return JSON with: \ n- "tags": array of relevant tags \ n- "summary": 2-3 sentence summary of this segment \ n- "call_quality_score": number 1-10 \ n- "overall_sentiment": "positive", "neutral", or "negative" ` }
2026-02-25 13:53:30 +05:30
/ >
< / div >
{ /* Advanced Configuration */ }
< div className = "border rounded-md" >
< button
type = "button"
className = "flex items-center gap-2 w-full p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
onClick = { ( ) = > setAdvancedOpen ( ! advancedOpen ) }
>
{ advancedOpen ? (
< ChevronDown className = "h-4 w-4" / >
) : (
< ChevronRight className = "h-4 w-4" / >
) }
Advanced Configuration
< / button >
{ advancedOpen && (
< div className = "px-3 pb-3 space-y-4 border-t pt-3" >
< div className = "grid gap-2" >
< Label > Minimum Call Duration ( seconds ) < / Label >
< Label className = "text-xs text-muted-foreground" >
Calls shorter than this duration will be skipped from QA analysis .
< / Label >
< Input
type = "number"
min = { 0 }
value = { minCallDuration }
onChange = { ( e ) = > setMinCallDuration ( Number ( e . target . value ) ) }
/ >
< / div >
< div className = "flex items-center space-x-2 p-2 border rounded-md bg-muted/20" >
< Switch
id = "qa-voicemail"
checked = { qaVoicemailCalls }
onCheckedChange = { setQaVoicemailCalls }
/ >
< Label htmlFor = "qa-voicemail" > QA Voicemail Calls < / Label >
< Label className = "text-xs text-muted-foreground ml-2" >
Run QA analysis on calls that reached voicemail .
< / Label >
< / div >
< div className = "grid gap-2" >
< Label > Sample Rate ( % ) < / Label >
< Label className = "text-xs text-muted-foreground" >
Percentage of eligible calls to run QA analysis on .
< / Label >
< Input
type = "number"
min = { 1 }
max = { 100 }
value = { qaSampleRate }
onChange = { ( e ) = >
setQaSampleRate (
Math . min ( 100 , Math . max ( 1 , Number ( e . target . value ) ) )
)
}
/ >
< / div >
< / div >
) }
< / div >
< / div >
) ;
} ;
QANode . displayName = "QANode" ;