Feature/agentflow v2 (#4298)

* agent flow v2

* chat message background

* conditon agent flow

* add sticky note

* update human input dynamic prompt

* add HTTP node

* add default tool icon

* fix export duplicate agentflow v2

* add agentflow v2 marketplaces

* refractor memoization, add iteration nodes

* add agentflow v2 templates

* add agentflow generator

* add migration scripts for mysql, mariadb, posrgres and fix date filters for executions

* update agentflow chat history config

* fix get all flows error after deletion and rename

* add previous nodes from parent node

* update generator prompt

* update run time state when using iteration nodes

* prevent looping connection, prevent duplication of start node, add executeflow node, add nodes agentflow, chat history variable

* update embed

* convert form input to string

* bump openai version

* add react rewards

* add prompt generator to prediction queue

* add array schema to overrideconfig

* UI touchup

* update embedded chat version

* fix node info dialog

* update start node and loop default iteration

* update UI fixes for agentflow v2

* fix async drop down

* add export import to agentflowsv2, executions, fix UI bugs

* add default empty object to flowlisttable

* add ability to share trace link publicly, allow MCP tool use for Agent and Assistant

* add runtime message length to variable, display conditions on UI

* fix array validation

* add ability to add knowledge from vector store and embeddings for agent

* add agent tool require human input

* add ephemeral memory to start node

* update agent flow node to show vs and embeddings icons

* feat: add import chat data functionality for AgentFlowV2

* feat: set chatMessage.executionId to null if not found in import JSON file or database

* fix: MariaDB execution migration script to utf8mb4_unicode_520_ci

---------

Co-authored-by: Ong Chung Yau <33013947+chungyau97@users.noreply.github.com>
Co-authored-by: chungyau97 <chungyau97@gmail.com>
This commit is contained in:
Henry Heng
2025-05-10 10:21:26 +08:00
committed by GitHub
parent 82e6f43b5c
commit 7924fbce0d
216 changed files with 33304 additions and 5269 deletions
+5 -2
View File
@@ -2,7 +2,7 @@ import client from './client'
const getAllChatflows = () => client.get('/chatflows?type=CHATFLOW')
const getAllAgentflows = () => client.get('/chatflows?type=MULTIAGENT')
const getAllAgentflows = (type) => client.get(`/chatflows?type=${type}`)
const getSpecificChatflow = (id) => client.get(`/chatflows/${id}`)
@@ -20,6 +20,8 @@ const getIsChatflowStreaming = (id) => client.get(`/chatflows-streaming/${id}`)
const getAllowChatflowUploads = (id) => client.get(`/chatflows-uploads/${id}`)
const generateAgentflow = (body) => client.post(`/agentflowv2-generator/generate`, body)
export default {
getAllChatflows,
getAllAgentflows,
@@ -30,5 +32,6 @@ export default {
updateChatflow,
deleteChatflow,
getIsChatflowStreaming,
getAllowChatflowUploads
getAllowChatflowUploads,
generateAgentflow
}
+15
View File
@@ -0,0 +1,15 @@
import client from './client'
const getAllExecutions = (params = {}) => client.get('/executions', { params })
const deleteExecutions = (executionIds) => client.delete('/executions', { data: { executionIds } })
const getExecutionById = (executionId) => client.get(`/executions/${executionId}`)
const getExecutionByIdPublic = (executionId) => client.get(`/public-executions/${executionId}`)
const updateExecution = (executionId, body) => client.put(`/executions/${executionId}`, body)
export default {
getAllExecutions,
deleteExecutions,
getExecutionById,
getExecutionByIdPublic,
updateExecution
}
+3 -1
View File
@@ -2,8 +2,10 @@ import client from './client'
const sendMessageAndGetPrediction = (id, input) => client.post(`/internal-prediction/${id}`, input)
const sendMessageAndStreamPrediction = (id, input) => client.post(`/internal-prediction/stream/${id}`, input)
const sendMessageAndGetPredictionPublic = (id, input) => client.post(`/prediction/${id}`, input)
export default {
sendMessageAndGetPrediction,
sendMessageAndStreamPrediction
sendMessageAndStreamPrediction,
sendMessageAndGetPredictionPublic
}
+7
View File
@@ -0,0 +1,7 @@
import client from './client'
const checkValidation = (id) => client.get(`/validation/${id}`)
export default {
checkValidation
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-key"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14.52 2c1.029 0 2.015 .409 2.742 1.136l3.602 3.602a3.877 3.877 0 0 1 0 5.483l-2.643 2.643a3.88 3.88 0 0 1 -4.941 .452l-.105 -.078l-5.882 5.883a3 3 0 0 1 -1.68 .843l-.22 .027l-.221 .009h-1.172c-1.014 0 -1.867 -.759 -1.991 -1.823l-.009 -.177v-1.172c0 -.704 .248 -1.386 .73 -1.96l.149 -.161l.414 -.414a1 1 0 0 1 .707 -.293h1v-1a1 1 0 0 1 .883 -.993l.117 -.007h1v-1a1 1 0 0 1 .206 -.608l.087 -.1l1.468 -1.469l-.076 -.103a3.9 3.9 0 0 1 -.678 -1.963l-.007 -.236c0 -1.029 .409 -2.015 1.136 -2.742l2.643 -2.643a3.88 3.88 0 0 1 2.741 -1.136m.495 5h-.02a2 2 0 1 0 0 4h.02a2 2 0 1 0 0 -4" /></svg>

After

Width:  |  Height:  |  Size: 817 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-tool"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5" /></svg>

After

Width:  |  Height:  |  Size: 407 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

@@ -46,6 +46,7 @@ $grey50: #fafafa;
$grey100: #f5f5f5;
$grey200: #eeeeee;
$grey300: #e0e0e0;
$grey400: #c4c4c4;
$grey500: #9e9e9e;
$grey600: #757575;
$grey700: #616161;
@@ -134,6 +135,7 @@ $darkTextSecondary: #8492c4;
grey100: $grey100;
grey200: $grey200;
grey300: $grey300;
grey400: $grey400;
grey500: $grey500;
grey600: $grey600;
grey700: $grey700;
+26
View File
@@ -120,3 +120,29 @@
transform: translateX(10px);
}
}
.tiptap {
.variable {
background-color: #b3f0b8;
border-radius: 0.4rem;
box-decoration-break: clone;
color: #0d7115;
padding: 0.1rem 0.3rem;
&::after {
content: '\200B';
}
}
}
.spin-animation {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+2 -1
View File
@@ -2,7 +2,8 @@ const config = {
// basename: only at build time to set, and Don't add '/' at end off BASENAME for breadcrumbs, also Don't put only '/' use blank('') instead,
basename: '',
defaultPath: '/chatflows',
fontFamily: `'Roboto', sans-serif`,
// You can specify multiple fallback fonts
fontFamily: `'Inter', 'Roboto', 'Arial', sans-serif`,
borderRadius: 12
}
@@ -54,6 +54,7 @@ import { useNavigate } from 'react-router-dom'
const dataToExport = [
'Agentflows',
'Agentflows V2',
'Assistants Custom',
'Assistants OpenAI',
'Assistants Azure',
@@ -62,6 +63,7 @@ const dataToExport = [
'Chat Feedbacks',
'Custom Templates',
'Document Stores',
'Executions',
'Tools',
'Variables'
]
@@ -256,6 +258,7 @@ const ProfileSection = ({ username, handleLogout }) => {
const onExport = (data) => {
const body = {}
if (data.includes('Agentflows')) body.agentflow = true
if (data.includes('Agentflows V2')) body.agentflowv2 = true
if (data.includes('Assistants Custom')) body.assistantCustom = true
if (data.includes('Assistants OpenAI')) body.assistantOpenAI = true
if (data.includes('Assistants Azure')) body.assistantAzure = true
@@ -264,6 +267,7 @@ const ProfileSection = ({ username, handleLogout }) => {
if (data.includes('Chat Feedbacks')) body.chat_feedback = true
if (data.includes('Custom Templates')) body.custom_template = true
if (data.includes('Document Stores')) body.document_store = true
if (data.includes('Executions')) body.execution = true
if (data.includes('Tools')) body.tool = true
if (data.includes('Variables')) body.variable = true
@@ -54,7 +54,7 @@ const ViewHeader = ({
<Box sx={{ display: 'flex', alignItems: 'start', flexDirection: 'column' }}>
<Typography
sx={{
fontSize: '2rem',
fontSize: '1.8rem',
fontWeight: 600,
display: '-webkit-box',
WebkitLineClamp: 3,
+23 -4
View File
@@ -8,11 +8,23 @@ import {
IconLock,
IconRobot,
IconVariable,
IconFiles
IconFiles,
IconListCheck
} from '@tabler/icons-react'
// constant
const icons = { IconUsersGroup, IconHierarchy, IconBuildingStore, IconKey, IconTool, IconLock, IconRobot, IconVariable, IconFiles }
const icons = {
IconListCheck,
IconUsersGroup,
IconHierarchy,
IconBuildingStore,
IconKey,
IconTool,
IconLock,
IconRobot,
IconVariable,
IconFiles
}
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
@@ -35,8 +47,15 @@ const dashboard = {
type: 'item',
url: '/agentflows',
icon: icons.IconUsersGroup,
breadcrumbs: true,
isBeta: true
breadcrumbs: true
},
{
id: 'executions',
title: 'Executions',
type: 'item',
url: '/executions',
icon: icons.IconListCheck,
breadcrumbs: true
},
{
id: 'assistants',
+14
View File
@@ -7,6 +7,8 @@ import MinimalLayout from '@/layout/MinimalLayout'
// canvas routing
const Canvas = Loadable(lazy(() => import('@/views/canvas')))
const MarketplaceCanvas = Loadable(lazy(() => import('@/views/marketplaces/MarketplaceCanvas')))
const CanvasV2 = Loadable(lazy(() => import('@/views/agentflowsv2/Canvas')))
const MarketplaceCanvasV2 = Loadable(lazy(() => import('@/views/agentflowsv2/MarketplaceCanvas')))
// ==============================|| CANVAS ROUTING ||============================== //
@@ -30,9 +32,21 @@ const CanvasRoutes = {
path: '/agentcanvas/:id',
element: <Canvas />
},
{
path: '/v2/agentcanvas',
element: <CanvasV2 />
},
{
path: '/v2/agentcanvas/:id',
element: <CanvasV2 />
},
{
path: '/marketplace/:id',
element: <MarketplaceCanvas />
},
{
path: '/v2/marketplace/:id',
element: <MarketplaceCanvasV2 />
}
]
}
@@ -0,0 +1,23 @@
import { lazy } from 'react'
// project imports
import Loadable from '@/ui-component/loading/Loadable'
import MinimalLayout from '@/layout/MinimalLayout'
// canvas routing
const PublicExecutionDetails = Loadable(lazy(() => import('@/views/agentexecutions/PublicExecutionDetails')))
// ==============================|| CANVAS ROUTING ||============================== //
const ExecutionRoutes = {
path: '/',
element: <MinimalLayout />,
children: [
{
path: '/execution/:id',
element: <PublicExecutionDetails />
}
]
}
export default ExecutionRoutes
+7
View File
@@ -39,6 +39,9 @@ const LoaderConfigPreviewChunks = Loadable(lazy(() => import('@/views/docstore/L
const VectorStoreConfigure = Loadable(lazy(() => import('@/views/docstore/VectorStoreConfigure')))
const VectorStoreQuery = Loadable(lazy(() => import('@/views/docstore/VectorStoreQuery')))
// execution routing
const Executions = Loadable(lazy(() => import('@/views/agentexecutions')))
// ==============================|| MAIN ROUTING ||============================== //
const MainRoutes = {
@@ -57,6 +60,10 @@ const MainRoutes = {
path: '/agentflows',
element: <Agentflows />
},
{
path: '/executions',
element: <Executions />
},
{
path: '/marketplaces',
element: <Marketplaces />
+2 -1
View File
@@ -4,10 +4,11 @@ import { useRoutes } from 'react-router-dom'
import MainRoutes from './MainRoutes'
import CanvasRoutes from './CanvasRoutes'
import ChatbotRoutes from './ChatbotRoutes'
import ExecutionRoutes from './ExecutionRoutes'
import config from '@/config'
// ==============================|| ROUTING RENDER ||============================== //
export default function ThemeRoutes() {
return useRoutes([MainRoutes, CanvasRoutes, ChatbotRoutes], config.basename)
return useRoutes([MainRoutes, CanvasRoutes, ChatbotRoutes, ExecutionRoutes], config.basename)
}
+95
View File
@@ -1,4 +1,22 @@
// constant
import {
IconLibrary,
IconTools,
IconFunctionFilled,
IconMessageCircleFilled,
IconRobot,
IconArrowsSplit,
IconPlayerPlayFilled,
IconSparkles,
IconReplaceUser,
IconRepeat,
IconSubtask,
IconNote,
IconWorld,
IconRelationOneToManyFilled,
IconVectorBezier2
} from '@tabler/icons-react'
export const gridSpacing = 3
export const drawerWidth = 260
export const appDrawerWidth = 320
@@ -8,3 +26,80 @@ export const baseURL = import.meta.env.VITE_API_BASE_URL || window.location.orig
export const uiBaseURL = import.meta.env.VITE_UI_BASE_URL || window.location.origin
export const FLOWISE_CREDENTIAL_ID = 'FLOWISE_CREDENTIAL_ID'
export const REDACTED_CREDENTIAL_VALUE = '_FLOWISE_BLANK_07167752-1a71-43b1-bf8f-4f32252165db'
export const AGENTFLOW_ICONS = [
{
name: 'conditionAgentflow',
icon: IconArrowsSplit,
color: '#FFB938'
},
{
name: 'startAgentflow',
icon: IconPlayerPlayFilled,
color: '#7EE787'
},
{
name: 'llmAgentflow',
icon: IconSparkles,
color: '#64B5F6'
},
{
name: 'agentAgentflow',
icon: IconRobot,
color: '#4DD0E1'
},
{
name: 'humanInputAgentflow',
icon: IconReplaceUser,
color: '#6E6EFD'
},
{
name: 'loopAgentflow',
icon: IconRepeat,
color: '#FFA07A'
},
{
name: 'directReplyAgentflow',
icon: IconMessageCircleFilled,
color: '#4DDBBB'
},
{
name: 'customFunctionAgentflow',
icon: IconFunctionFilled,
color: '#E4B7FF'
},
{
name: 'toolAgentflow',
icon: IconTools,
color: '#d4a373'
},
{
name: 'retrieverAgentflow',
icon: IconLibrary,
color: '#b8bedd'
},
{
name: 'conditionAgentAgentflow',
icon: IconSubtask,
color: '#ff8fab'
},
{
name: 'stickyNoteAgentflow',
icon: IconNote,
color: '#fee440'
},
{
name: 'httpAgentflow',
icon: IconWorld,
color: '#FF7F7F'
},
{
name: 'iterationAgentflow',
icon: IconRelationOneToManyFilled,
color: '#9C89B8'
},
{
name: 'executeFlowAgentflow',
icon: IconVectorBezier2,
color: '#a3b18a'
}
]
@@ -1,8 +1,8 @@
import { createContext, useState } from 'react'
import { useDispatch } from 'react-redux'
import PropTypes from 'prop-types'
import { getUniqueNodeId } from '@/utils/genericHelper'
import { cloneDeep } from 'lodash'
import { getUniqueNodeId, showHideInputParams } from '@/utils/genericHelper'
import { cloneDeep, isEqual } from 'lodash'
import { SET_DIRTY } from '@/store/actions'
const initialValue = {
@@ -10,7 +10,8 @@ const initialValue = {
setReactFlowInstance: () => {},
duplicateNode: () => {},
deleteNode: () => {},
deleteEdge: () => {}
deleteEdge: () => {},
onNodeDataChange: () => {}
}
export const flowContext = createContext(initialValue)
@@ -19,10 +20,112 @@ export const ReactFlowContext = ({ children }) => {
const dispatch = useDispatch()
const [reactFlowInstance, setReactFlowInstance] = useState(null)
const onAgentflowNodeStatusUpdate = ({ nodeId, status, error }) => {
reactFlowInstance.setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
node.data = {
...node.data,
status,
error
}
}
return node
})
)
}
const clearAgentflowNodeStatus = () => {
reactFlowInstance.setNodes((nds) =>
nds.map((node) => {
node.data = {
...node.data,
status: undefined,
error: undefined
}
return node
})
)
}
const onNodeDataChange = ({ nodeId, inputParam, newValue }) => {
const updatedNodes = reactFlowInstance.getNodes().map((node) => {
if (node.id === nodeId) {
const updatedInputs = { ...node.data.inputs }
updatedInputs[inputParam.name] = newValue
const updatedInputParams = showHideInputParams({
...node.data,
inputs: updatedInputs
})
// Remove inputs with display set to false
Object.keys(updatedInputs).forEach((key) => {
const input = updatedInputParams.find((param) => param.name === key)
if (input && input.display === false) {
delete updatedInputs[key]
}
})
return {
...node,
data: {
...node.data,
inputParams: updatedInputParams,
inputs: updatedInputs
}
}
}
return node
})
// Check if any node's inputParams have changed before updating
const hasChanges = updatedNodes.some(
(node, index) => !isEqual(node.data.inputParams, reactFlowInstance.getNodes()[index].data.inputParams)
)
if (hasChanges) {
reactFlowInstance.setNodes(updatedNodes)
}
}
const deleteNode = (nodeid) => {
deleteConnectedInput(nodeid, 'node')
reactFlowInstance.setNodes(reactFlowInstance.getNodes().filter((n) => n.id !== nodeid))
reactFlowInstance.setEdges(reactFlowInstance.getEdges().filter((ns) => ns.source !== nodeid && ns.target !== nodeid))
// Gather all nodes to be deleted (parent and all descendants)
const nodesToDelete = new Set()
// Helper function to collect all descendant nodes recursively
const collectDescendants = (parentId) => {
const childNodes = reactFlowInstance.getNodes().filter((node) => node.parentNode === parentId)
childNodes.forEach((childNode) => {
nodesToDelete.add(childNode.id)
collectDescendants(childNode.id)
})
}
// Collect all descendants first
collectDescendants(nodeid)
// Add the parent node itself last
nodesToDelete.add(nodeid)
// Clean up inputs for all nodes to be deleted
nodesToDelete.forEach((id) => {
if (id !== nodeid) {
// Skip parent node as it's already processed at the beginning
deleteConnectedInput(id, 'node')
}
})
// Filter out all nodes and edges in a single operation
reactFlowInstance.setNodes((nodes) => nodes.filter((node) => !nodesToDelete.has(node.id)))
// Remove all edges connected to any of the deleted nodes
reactFlowInstance.setEdges((edges) => edges.filter((edge) => !nodesToDelete.has(edge.source) && !nodesToDelete.has(edge.target)))
dispatch({ type: SET_DIRTY })
}
@@ -72,7 +175,7 @@ export const ReactFlowContext = ({ children }) => {
}
}
const duplicateNode = (id) => {
const duplicateNode = (id, distance = 50) => {
const nodes = reactFlowInstance.getNodes()
const originalNode = nodes.find((n) => n.id === id)
if (originalNode) {
@@ -83,16 +186,17 @@ export const ReactFlowContext = ({ children }) => {
...clonedNode,
id: newNodeId,
position: {
x: clonedNode.position.x + 400,
x: clonedNode.position.x + clonedNode.width + distance,
y: clonedNode.position.y
},
positionAbsolute: {
x: clonedNode.positionAbsolute.x + 400,
x: clonedNode.positionAbsolute.x + clonedNode.width + distance,
y: clonedNode.positionAbsolute.y
},
data: {
...clonedNode.data,
id: newNodeId
id: newNodeId,
label: clonedNode.data.label + ` (${newNodeId.split('_').pop()})`
},
selected: false
}
@@ -147,7 +251,10 @@ export const ReactFlowContext = ({ children }) => {
setReactFlowInstance,
deleteNode,
deleteEdge,
duplicateNode
duplicateNode,
onAgentflowNodeStatusUpdate,
clearAgentflowNodeStatus,
onNodeDataChange
}}
>
{children}
+4
View File
@@ -78,6 +78,10 @@ export default function themePalette(theme) {
paper: theme.paper,
default: theme.backgroundDefault
},
textBackground: {
main: theme.customization.isDarkMode ? theme.colors?.darkPrimary800 : theme.colors?.grey50,
border: theme.customization.isDarkMode ? theme.colors?.transparent : theme.colors?.grey400
},
card: {
main: theme.customization.isDarkMode ? theme.colors?.darkPrimaryMain : theme.colors?.paper,
light: theme.customization.isDarkMode ? theme.colors?.darkPrimary200 : theme.colors?.paper,
@@ -0,0 +1,261 @@
import { useState, useEffect, useContext } from 'react'
import { useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import { Chip, Box, Button, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconTrash, IconPlus } from '@tabler/icons-react'
import NodeInputHandler from '@/views/canvas/NodeInputHandler'
import { showHideInputs } from '@/utils/genericHelper'
import { cloneDeep } from 'lodash'
import { flowContext } from '@/store/context/ReactFlowContext'
export const ArrayRenderer = ({ inputParam, data, disabled }) => {
const [arrayItems, setArrayItems] = useState([]) // these are the actual values. Ex: [{name: 'John', age: 30}, {name: 'Jane', age: 25}]
const [itemParameters, setItemParameters] = useState([]) // these are the input parameters for each array item. Ex: [{label: 'Name', type: 'string', display: true}, {label: 'age', type: 'number', display: false}]
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const { reactFlowInstance } = useContext(flowContext)
// Handler for when input values change within array items
const handleItemInputChange = ({ inputParam: changedParam, newValue }, itemIndex) => {
// Create deep copy to avoid mutating state directly
let clonedData = cloneDeep(data)
// Update the specific array item that changed
const updatedArrayItems = [...arrayItems]
const updatedItem = { ...updatedArrayItems[itemIndex] }
// Reset the value of fields which has show/hide rules, so the old values don't persist
for (let i = 0; i < inputParam.array.length; i += 1) {
const fieldDef = inputParam.array[i]
if (fieldDef.show || fieldDef.hide) {
updatedItem[fieldDef.name] = fieldDef.default || ''
}
}
// Set the new value for the changed field
updatedItem[changedParam.name] = newValue
updatedArrayItems[itemIndex] = updatedItem
// Update local state and parent data
setArrayItems(updatedArrayItems)
data.inputs[inputParam.name] = updatedArrayItems
clonedData.inputs[inputParam.name] = updatedArrayItems
// Recalculate display parameters based on new values
const newItemParams = showHideInputs(clonedData, 'inputParams', cloneDeep(inputParam.array), itemIndex)
if (newItemParams.length) {
const updatedItemParams = [...itemParameters]
updatedItemParams[itemIndex] = newItemParams
setItemParameters(updatedItemParams)
}
}
// Initialize array items and parameters when component mounts or data changes
useEffect(() => {
const initialArrayItems = data.inputs[inputParam.name] || []
setArrayItems(initialArrayItems)
// Calculate initial display parameters for each array item
const initialItemParameters = []
for (let i = 0; i < initialArrayItems.length; i += 1) {
const itemParams = showHideInputs(data, 'inputParams', cloneDeep(inputParam.array), i)
if (itemParams.length) {
initialItemParameters.push(itemParams)
}
}
setItemParameters(initialItemParameters)
}, [data, inputParam])
const updateOutputAnchors = (items, type, indexToDelete) => {
if (data.name !== 'conditionAgentflow' && data.name !== 'conditionAgentAgentflow') return
const updatedOutputs = items.map((_, i) => ({
id: `${data.id}-output-${i}`,
label: i,
name: i,
description: `Condition ${i}`
}))
// always append additional output anchor for ELSE for condition
if (data.name === 'conditionAgentflow') {
updatedOutputs.push({
id: `${data.id}-output-${items.length}`,
label: items.length,
name: items.length,
description: 'Else'
})
}
data.outputAnchors = updatedOutputs
const nodes = reactFlowInstance.getNodes()
// Update the current node with new output anchors
const updatedNodes = nodes.map((node) => {
if (node.id === data.id) {
return {
...node,
data: {
...node.data,
outputAnchors: updatedOutputs
}
}
}
return node
})
reactFlowInstance.setNodes(updatedNodes)
// Update edges if an item is deleted
if (type === 'DELETE') {
const edges = reactFlowInstance.getEdges()
const updatedEdges = edges.filter((edge) => {
if (edge.sourceHandle && edge.sourceHandle.includes(data.id)) {
const sourceHandleIndex = edge.sourceHandle.split('-').pop()
if (sourceHandleIndex === indexToDelete.toString()) {
return false
}
}
return true
})
reactFlowInstance.setEdges(updatedEdges)
}
}
// Handler for adding new array items
const handleAddItem = () => {
// Initialize new item with default values
let newItem = {}
for (const fieldDef of inputParam.array) {
newItem[fieldDef.name] = fieldDef.default || ''
}
/*if (inputParam.default?.length) {
newItem = inputParam.default[0]
}*/
// Update array items
const updatedArrayItems = [...arrayItems, newItem]
setArrayItems(updatedArrayItems)
data.inputs[inputParam.name] = updatedArrayItems
// Calculate display parameters for all items including new one
const updatedItemParameters = []
for (let i = 0; i < updatedArrayItems.length; i += 1) {
const itemParams = showHideInputs(data, 'inputParams', cloneDeep(inputParam.array), i)
if (itemParams.length) {
updatedItemParameters.push(itemParams)
}
}
setItemParameters(updatedItemParameters)
updateOutputAnchors(updatedArrayItems, 'ADD')
}
// Handler for deleting array items
const handleDeleteItem = (indexToDelete) => {
const updatedArrayItems = arrayItems.filter((_, i) => i !== indexToDelete)
setArrayItems(updatedArrayItems)
data.inputs[inputParam.name] = updatedArrayItems
const updatedItemParameters = itemParameters.filter((_, i) => i !== indexToDelete)
setItemParameters(updatedItemParameters)
updateOutputAnchors(updatedArrayItems, 'DELETE', indexToDelete)
}
const isDeleteButtonVisible = (data.name !== 'conditionAgentflow' && data.name !== 'conditionAgentAgentflow') || arrayItems.length > 1
return (
<>
{/* Render each array item */}
{arrayItems.map((itemValues, index) => {
// Create item data directly from parent data
const itemData = {
...data,
inputs: itemValues,
inputParams: itemParameters[index] || []
}
return (
<Box
sx={{
p: 2,
mt: 2,
mb: 1,
border: 1,
borderColor: theme.palette.grey[900] + 25,
borderRadius: 2,
position: 'relative'
}}
key={index}
>
{/* Delete button for array item */}
{isDeleteButtonVisible && (
<IconButton
title='Delete'
onClick={() => handleDeleteItem(index)}
sx={{
position: 'absolute',
height: '35px',
width: '35px',
right: 10,
top: 10,
color: customization?.isDarkMode ? theme.palette.grey[300] : 'inherit',
'&:hover': { color: 'red' }
}}
>
<IconTrash />
</IconButton>
)}
<Chip
label={`${index}`}
size='small'
sx={{ position: 'absolute', right: isDeleteButtonVisible ? 45 : 10, top: 16 }}
/>
{/* Render input fields for array item */}
{itemParameters[index]
.filter((param) => param.display !== false)
.map((param, _index) => (
<NodeInputHandler
disabled={disabled}
key={_index}
inputParam={param}
data={itemData}
isAdditionalParams={true}
parentParamForArray={inputParam}
arrayIndex={index}
onCustomDataChange={({ inputParam, newValue }) => {
handleItemInputChange({ inputParam, newValue }, index)
}}
/>
))}
</Box>
)
})}
{/* Add new item button */}
<Button
fullWidth
size='small'
variant='outlined'
sx={{ borderRadius: '16px', mt: 2 }}
startIcon={<IconPlus />}
onClick={handleAddItem}
>
Add {inputParam.label}
</Button>
</>
)
}
ArrayRenderer.propTypes = {
inputParam: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
disabled: PropTypes.bool
}
@@ -166,7 +166,11 @@ export default function FlowListMenu({ chatflow, isAgentCanvas, setError, update
}
try {
await updateChatflowApi.request(chatflow.id, updateBody)
await updateFlowsApi.request()
if (isAgentCanvas && localStorage.getItem('agentFlowVersion') === 'v2') {
await updateFlowsApi.request('AGENTFLOW')
} else {
await updateFlowsApi.request(isAgentCanvas ? 'MULTIAGENT' : undefined)
}
} catch (error) {
if (setError) setError(error)
enqueueSnackbar({
@@ -205,7 +209,7 @@ export default function FlowListMenu({ chatflow, isAgentCanvas, setError, update
}
try {
await updateChatflowApi.request(chatflow.id, updateBody)
await updateFlowsApi.request()
await updateFlowsApi.request(isAgentCanvas ? 'AGENTFLOW' : undefined)
} catch (error) {
if (setError) setError(error)
enqueueSnackbar({
@@ -237,7 +241,11 @@ export default function FlowListMenu({ chatflow, isAgentCanvas, setError, update
if (isConfirmed) {
try {
await chatflowsApi.deleteChatflow(chatflow.id)
await updateFlowsApi.request()
if (isAgentCanvas && localStorage.getItem('agentFlowVersion') === 'v2') {
await updateFlowsApi.request('AGENTFLOW')
} else {
await updateFlowsApi.request(isAgentCanvas ? 'MULTIAGENT' : undefined)
}
} catch (error) {
if (setError) setError(error)
enqueueSnackbar({
+44 -19
View File
@@ -29,7 +29,7 @@ const CardWrapper = styled(MainCard)(({ theme }) => ({
// ===========================|| CONTRACT CARD ||=========================== //
const ItemCard = ({ data, images, onClick }) => {
const ItemCard = ({ data, images, icons, onClick }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
@@ -106,7 +106,7 @@ const ItemCard = ({ data, images, onClick }) => {
</span>
)}
</Box>
{images && (
{(images?.length > 0 || icons?.length > 0) && (
<Box
sx={{
display: 'flex',
@@ -115,24 +115,48 @@ const ItemCard = ({ data, images, onClick }) => {
gap: 1
}}
>
{images.slice(0, images.length > 3 ? 3 : images.length).map((img) => (
<Box
key={img}
sx={{
width: 30,
height: 30,
borderRadius: '50%',
backgroundColor: customization.isDarkMode
? theme.palette.common.white
: theme.palette.grey[300] + 75
}}
>
<img style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }} alt='' src={img} />
</Box>
))}
{images.length > 3 && (
{[
...(images || []).map((img) => ({ type: 'image', src: img })),
...(icons || []).map((ic) => ({ type: 'icon', icon: ic.icon, color: ic.color }))
]
.slice(0, 3)
.map((item, index) =>
item.type === 'image' ? (
<Box
key={item.src}
sx={{
width: 30,
height: 30,
borderRadius: '50%',
backgroundColor: customization.isDarkMode
? theme.palette.common.white
: theme.palette.grey[300] + 75
}}
>
<img
style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }}
alt=''
src={item.src}
/>
</Box>
) : (
<div
key={index}
style={{
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<item.icon size={25} color={item.color} />
</div>
)
)}
{images?.length + (icons?.length || 0) > 3 && (
<Typography sx={{ alignItems: 'center', display: 'flex', fontSize: '.9rem', fontWeight: 200 }}>
+ {images.length - 3} More
+ {images?.length + (icons?.length || 0) - 3} More
</Typography>
)}
</Box>
@@ -146,6 +170,7 @@ const ItemCard = ({ data, images, onClick }) => {
ItemCard.propTypes = {
data: PropTypes.object,
images: PropTypes.array,
icons: PropTypes.array,
onClick: PropTypes.func
}
@@ -7,14 +7,14 @@ import MainCard from './MainCard'
const NodeCardWrapper = styled(MainCard)(({ theme }) => ({
background: theme.palette.card.main,
color: theme.darkTextPrimary,
border: 'solid 1px',
borderColor: theme.palette.primary[200] + 75,
border: `1px solid ${theme.customization?.isDarkMode ? theme.palette.grey[900] + 25 : theme.palette.primary[200] + 75}`,
width: '300px',
height: 'auto',
padding: '10px',
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)',
boxShadow: `rgba(0, 0, 0, 0.05) 0px 0px 0px 1px`,
'&:hover': {
borderColor: theme.palette.primary.main
borderColor: theme.palette.primary.main,
boxShadow: `rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px`
}
}))
@@ -47,15 +47,17 @@ const AdditionalParamsDialog = ({ show, dialogProps, onCancel }) => {
overflowX: 'hidden'
}}
>
{inputParams.map((inputParam, index) => (
<NodeInputHandler
disabled={dialogProps.disabled}
key={index}
inputParam={inputParam}
data={data}
isAdditionalParams={true}
/>
))}
{inputParams
.filter((inputParam) => inputParam.display !== false)
.map((inputParam, index) => (
<NodeInputHandler
disabled={dialogProps.disabled}
key={index}
inputParam={inputParam}
data={data}
isAdditionalParams={true}
/>
))}
</PerfectScrollbar>
</DialogContent>
</Dialog>
@@ -0,0 +1,369 @@
import { createPortal } from 'react-dom'
import { cloneDeep } from 'lodash'
import { useState, useEffect, useContext } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import { Box, Typography, OutlinedInput, DialogActions, Button, Dialog, DialogContent, DialogTitle, LinearProgress } from '@mui/material'
import chatflowsApi from '@/api/chatflows'
import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
import { IconX, IconSparkles, IconArrowLeft } from '@tabler/icons-react'
import useNotifier from '@/utils/useNotifier'
import { LoadingButton } from '@mui/lab'
import generatorGIF from '@/assets/images/agentflow-generator.gif'
import { flowContext } from '@/store/context/ReactFlowContext'
import { Dropdown } from '@/ui-component/dropdown/Dropdown'
import { useTheme } from '@mui/material/styles'
import assistantsApi from '@/api/assistants'
import { baseURL } from '@/store/constant'
import { initNode } from '@/utils/genericHelper'
import DocStoreInputHandler from '@/views/docstore/DocStoreInputHandler'
import useApi from '@/hooks/useApi'
const defaultInstructions = [
{
text: 'An agent that can autonomously search the web and generate report'
},
{
text: 'Summarize a document'
},
{
text: 'Generate response to user queries and send it to Slack'
},
{
text: 'A team of agents that can handle all customer queries'
}
]
const AgentflowGeneratorDialog = ({ show, dialogProps, onCancel, onConfirm }) => {
const portalElement = document.getElementById('portal')
const [customAssistantInstruction, setCustomAssistantInstruction] = useState('')
const [generatedInstruction, setGeneratedInstruction] = useState('')
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState(0)
const [chatModelsComponents, setChatModelsComponents] = useState([])
const [chatModelsOptions, setChatModelsOptions] = useState([])
const [selectedChatModel, setSelectedChatModel] = useState({})
const customization = useSelector((state) => state.customization)
const getChatModelsApi = useApi(assistantsApi.getChatModels)
const { reactFlowInstance } = useContext(flowContext)
const theme = useTheme()
// ==============================|| Snackbar ||============================== //
const dispatch = useDispatch()
useNotifier()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
useEffect(() => {
if (getChatModelsApi.data) {
setChatModelsComponents(getChatModelsApi.data)
// Set options
const options = getChatModelsApi.data.map((chatModel) => ({
label: chatModel.label,
name: chatModel.name,
imageSrc: `${baseURL}/api/v1/node-icon/${chatModel.name}`
}))
setChatModelsOptions(options)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getChatModelsApi.data])
// Simulate progress for the fake progress bar
useEffect(() => {
let timer
if (loading) {
setProgress(0)
timer = setInterval(() => {
setProgress((prevProgress) => {
// Slowly increase to 95% to give the impression of work happening
// Last 5% will complete when the actual work is done
if (prevProgress >= 95) {
clearInterval(timer)
return 95
}
// Speed up in the middle, slow at the beginning and end
const increment = prevProgress < 30 ? 3 : prevProgress < 60 ? 5 : prevProgress < 80 ? 2 : 0.5
return Math.min(prevProgress + increment, 95)
})
}, 500)
} else {
// When loading is done, immediately set to 100%
setProgress(100)
}
return () => {
if (timer) {
clearInterval(timer)
}
}
}, [loading])
const onGenerate = async () => {
if (!customAssistantInstruction.trim()) return
try {
setLoading(true)
const response = await chatflowsApi.generateAgentflow({
question: customAssistantInstruction.trim(),
selectedChatModel: selectedChatModel
})
if (response.data && response.data.nodes && response.data.edges) {
reactFlowInstance.setNodes(response.data.nodes)
reactFlowInstance.setEdges(response.data.edges)
onConfirm()
} else {
enqueueSnackbar({
message: response.error || 'Failed to generate agentflow',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
} catch (error) {
enqueueSnackbar({
message: error.response?.data?.message || 'Failed to generate agentflow',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
} finally {
setLoading(false)
}
}
// clear the state when dialog is closed
useEffect(() => {
if (!show) {
setCustomAssistantInstruction('')
setGeneratedInstruction('')
setProgress(0)
} else {
getChatModelsApi.request()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show])
const component = show ? (
<>
<Dialog
fullWidth
maxWidth={loading ? 'sm' : 'md'}
open={show}
onClose={loading ? null : onCancel}
aria-labelledby='alert-dialog-title'
aria-describedby='alert-dialog-description'
>
<DialogTitle sx={{ fontSize: '1rem' }} id='alert-dialog-title'>
{dialogProps.title}
</DialogTitle>
<DialogContent>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
<img src={generatorGIF} alt='Generating Agentflow' style={{ maxWidth: '100%', height: 'auto' }} />
<Typography variant='h5' sx={{ mt: 2 }}>
Generating your Agentflow...
</Typography>
<Box sx={{ width: '100%', mt: 2 }}>
<LinearProgress
variant='determinate'
value={progress}
sx={{
height: 10,
borderRadius: 5,
'& .MuiLinearProgress-bar': {
background: 'linear-gradient(45deg, #FF6B6B 30%, #FF8E53 90%)',
borderRadius: 5
}
}}
/>
<Typography variant='body2' color='text.secondary' align='center' sx={{ mt: 1 }}>
{`${Math.round(progress)}%`}
</Typography>
</Box>
</div>
) : (
<>
<span>{dialogProps.description}</span>
<div
style={{
display: 'block',
flexDirection: 'row',
width: '100%',
marginTop: '25px'
}}
>
{defaultInstructions.map((instruction, index) => {
return (
<Button
size='small'
key={index}
sx={{
textTransform: 'none',
mr: 1,
mb: 1,
borderRadius: '16px',
border: 'none',
backgroundColor: customization.isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
'&:hover': {
backgroundColor: customization.isDarkMode
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.06)',
boxShadow: '0 4px 8px rgba(0,0,0,0.15)'
}
}}
variant='contained'
color='inherit'
onClick={() => {
setCustomAssistantInstruction(instruction.text)
setGeneratedInstruction('')
}}
>
{instruction.text}
</Button>
)
})}
</div>
{!generatedInstruction && (
<OutlinedInput
sx={{ mt: 2, width: '100%' }}
type={'text'}
multiline={true}
rows={12}
disabled={loading}
value={customAssistantInstruction}
placeholder={'Describe your agent here'}
onChange={(event) => setCustomAssistantInstruction(event.target.value)}
/>
)}
{generatedInstruction && (
<OutlinedInput
sx={{ mt: 2, width: '100%' }}
type={'text'}
multiline={true}
rows={12}
value={generatedInstruction}
onChange={(event) => setGeneratedInstruction(event.target.value)}
/>
)}
<Box sx={{ mt: 2 }}>
<div style={{ display: 'flex', flexDirection: 'row' }}>
<Typography>
Select model to generate agentflow<span style={{ color: 'red' }}>&nbsp;*</span>
</Typography>
</div>
<Dropdown
key={JSON.stringify(selectedChatModel)}
name={'chatModel'}
options={chatModelsOptions ?? []}
onSelect={(newValue) => {
if (!newValue) {
setSelectedChatModel({})
} else {
const foundChatComponent = chatModelsComponents.find((chatModel) => chatModel.name === newValue)
if (foundChatComponent) {
const chatModelId = `${foundChatComponent.name}_0`
const clonedComponent = cloneDeep(foundChatComponent)
const initChatModelData = initNode(clonedComponent, chatModelId)
setSelectedChatModel(initChatModelData)
}
}
}}
value={selectedChatModel ? selectedChatModel?.name : 'choose an option'}
/>
</Box>
{selectedChatModel && Object.keys(selectedChatModel).length > 0 && (
<Box
sx={{
p: 0,
mt: 1,
mb: 1,
border: 1,
borderColor: theme.palette.grey[900] + 25,
borderRadius: 2
}}
>
{(selectedChatModel.inputParams ?? [])
.filter((inputParam) => !inputParam.hidden)
.map((inputParam, index) => (
<DocStoreInputHandler key={index} inputParam={inputParam} data={selectedChatModel} />
))}
</Box>
)}
</>
)}
</DialogContent>
<DialogActions sx={{ pb: 3, pr: 3 }}>
{loading ? null : (
<>
{!generatedInstruction && (
<LoadingButton
loading={loading}
variant='contained'
onClick={() => {
onGenerate()
}}
sx={{
background: 'linear-gradient(45deg, #FF6B6B 30%, #FF8E53 90%)',
'&:hover': { background: 'linear-gradient(45deg, #FF8E53 30%, #FF6B6B 90%)' }
}}
startIcon={<IconSparkles size={20} />}
disabled={
loading ||
!customAssistantInstruction.trim() ||
!selectedChatModel ||
!Object.keys(selectedChatModel).length
}
>
Generate
</LoadingButton>
)}
{generatedInstruction && (
<Button
variant='outlined'
startIcon={<IconArrowLeft size={20} />}
onClick={() => {
setGeneratedInstruction('')
}}
>
Back
</Button>
)}
</>
)}
</DialogActions>
</Dialog>
</>
) : null
return createPortal(component, portalElement)
}
AgentflowGeneratorDialog.propTypes = {
show: PropTypes.bool,
dialogProps: PropTypes.object,
onConfirm: PropTypes.func,
onCancel: PropTypes.func
}
export default AgentflowGeneratorDialog
@@ -58,17 +58,19 @@ const ConditionDialog = ({ show, dialogProps, onCancel, onConfirm }) => {
))}
</TabsList>
</Tabs>
{inputParam.tabs.map((inputChildParam, index) => (
<TabPanel key={index} value={tabValue} index={index}>
<NodeInputHandler
disabled={inputChildParam.disabled}
inputParam={inputChildParam}
data={data}
isAdditionalParams={true}
disablePadding={true}
/>
</TabPanel>
))}
{inputParam.tabs
.filter((inputParam) => inputParam.display !== false)
.map((inputChildParam, index) => (
<TabPanel key={index} value={tabValue} index={index}>
<NodeInputHandler
disabled={inputChildParam.disabled}
inputParam={inputChildParam}
data={data}
isAdditionalParams={true}
disablePadding={true}
/>
</TabPanel>
))}
</>
)}
</>
@@ -0,0 +1,231 @@
import { createPortal } from 'react-dom'
import { useState, useEffect } from 'react'
import { useDispatch } from 'react-redux'
import PropTypes from 'prop-types'
import PerfectScrollbar from 'react-perfect-scrollbar'
// MUI
import { Button, Dialog, DialogActions, DialogContent, Typography, Box } from '@mui/material'
import { styled } from '@mui/material/styles'
// Project Import
import { StyledButton } from '@/ui-component/button/StyledButton'
// TipTap
import { useEditor, EditorContent } from '@tiptap/react'
import Placeholder from '@tiptap/extension-placeholder'
import { mergeAttributes } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Mention from '@tiptap/extension-mention'
import { suggestionOptions } from '@/ui-component/input/suggestionOption'
import { getAvailableNodesForVariable } from '@/utils/genericHelper'
// Store
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
// Add styled component for editor wrapper
const StyledEditorContent = styled(EditorContent)(({ theme, rows }) => ({
'& .ProseMirror': {
padding: '0px 14px',
height: rows ? `${rows * 1.4375}rem` : '2.4rem',
overflowY: rows ? 'auto' : 'hidden',
overflowX: rows ? 'auto' : 'hidden',
lineHeight: rows ? '1.4375em' : '0.875em',
fontWeight: 500,
color: theme.palette.grey[900],
border: `1px solid ${theme.palette.textBackground.border}`,
borderRadius: '10px',
backgroundColor: theme.palette.textBackground.main,
boxSizing: 'border-box',
whiteSpace: rows ? 'pre-wrap' : 'nowrap',
'&:hover': {
borderColor: theme.palette.text.primary,
cursor: 'text'
},
'&:focus': {
borderColor: theme.palette.primary.main,
boxShadow: `0 0 0 0px ${theme.palette.primary.main}`,
outline: 'none'
},
'&[disabled]': {
backgroundColor: theme.palette.action.disabledBackground,
color: theme.palette.action.disabled
},
// Placeholder for first paragraph when editor is empty
'& p.is-editor-empty:first-of-type::before': {
content: 'attr(data-placeholder)',
float: 'left',
color: theme.palette.text.primary,
opacity: 0.4,
pointerEvents: 'none',
height: 0
}
}
}))
// define your extension array
const extensions = (availableNodesForVariable, availableState, acceptNodeOutputAsVariable, nodes, nodeData, isNodeInsideInteration) => [
StarterKit,
Mention.configure({
HTMLAttributes: {
class: 'variable'
},
renderHTML({ options, node }) {
return [
'span',
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`${options.suggestion.char} ${node.attrs.label ?? node.attrs.id} }}`
]
},
suggestion: suggestionOptions(
availableNodesForVariable,
availableState,
acceptNodeOutputAsVariable,
nodes,
nodeData,
isNodeInsideInteration
),
deleteTriggerWithBackspace: true
})
]
const ExpandRichInputDialog = ({ show, dialogProps, onCancel, onInputHintDialogClicked, onConfirm }) => {
const portalElement = document.getElementById('portal')
const dispatch = useDispatch()
const [inputValue, setInputValue] = useState('')
const [inputParam, setInputParam] = useState(null)
const [availableNodesForVariable, setAvailableNodesForVariable] = useState([])
const [availableState, setAvailableState] = useState([])
const [nodeData, setNodeData] = useState({})
const [isNodeInsideInteration, setIsNodeInsideInteration] = useState(false)
useEffect(() => {
if (dialogProps.value) {
setInputValue(dialogProps.value)
}
if (dialogProps.inputParam) {
setInputParam(dialogProps.inputParam)
}
return () => {
setInputValue('')
setInputParam(null)
}
}, [dialogProps])
useEffect(() => {
if (show) dispatch({ type: SHOW_CANVAS_DIALOG })
else dispatch({ type: HIDE_CANVAS_DIALOG })
return () => dispatch({ type: HIDE_CANVAS_DIALOG })
}, [show, dispatch])
useEffect(() => {
if (!dialogProps.disabled && dialogProps.nodes && dialogProps.edges && dialogProps.nodeId && inputParam) {
const nodesForVariable = inputParam?.acceptVariable
? getAvailableNodesForVariable(dialogProps.nodes, dialogProps.edges, dialogProps.nodeId, inputParam.id)
: []
setAvailableNodesForVariable(nodesForVariable)
const startAgentflowNode = dialogProps.nodes.find((node) => node.data.name === 'startAgentflow')
const state = startAgentflowNode?.data?.inputs?.startState
setAvailableState(state)
const agentflowNode = dialogProps.nodes.find((node) => node.data.id === dialogProps.nodeId)
setNodeData(agentflowNode?.data)
setIsNodeInsideInteration(dialogProps.nodes.find((node) => node.data.id === dialogProps.nodeId)?.extent === 'parent')
}
}, [dialogProps.disabled, inputParam, dialogProps.nodes, dialogProps.edges, dialogProps.nodeId])
const editor = useEditor(
{
extensions: [
...extensions(
availableNodesForVariable,
availableState,
inputParam?.acceptNodeOutputAsVariable,
dialogProps.nodes,
nodeData,
isNodeInsideInteration
),
Placeholder.configure({ placeholder: inputParam?.placeholder })
],
content: inputValue,
onUpdate: ({ editor }) => {
setInputValue(editor.getHTML())
},
editable: !dialogProps.disabled
},
[availableNodesForVariable]
)
// Focus the editor when dialog opens
useEffect(() => {
if (show && editor) {
setTimeout(() => {
editor.commands.focus()
}, 100)
}
}, [show, editor])
const component = show ? (
<Dialog open={show} fullWidth maxWidth='md' aria-labelledby='alert-dialog-title' aria-describedby='alert-dialog-description'>
<DialogContent>
<div style={{ display: 'flex', flexDirection: 'row' }}>
{inputParam && (
<div style={{ flex: 70, width: '100%' }}>
<div style={{ marginBottom: '10px', display: 'flex', flexDirection: 'row' }}>
<Typography variant='h4'>{inputParam.label}</Typography>
<div style={{ flex: 1 }} />
{inputParam.hint && (
<Button
sx={{ p: 0, px: 2 }}
color='secondary'
variant='text'
onClick={() => {
onInputHintDialogClicked(inputParam.hint)
}}
>
{inputParam.hint.label}
</Button>
)}
</div>
<PerfectScrollbar
style={{
borderRadius: '12px',
height: '100%',
maxHeight: 'calc(100vh - 220px)',
overflowX: 'hidden'
}}
>
<Box sx={{ mt: 1, border: '' }}>
<StyledEditorContent editor={editor} rows={15} />
</Box>
</PerfectScrollbar>
</div>
)}
</div>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>{dialogProps.cancelButtonName}</Button>
<StyledButton disabled={dialogProps.disabled} variant='contained' onClick={() => onConfirm(inputValue, inputParam.name)}>
{dialogProps.confirmButtonName}
</StyledButton>
</DialogActions>
</Dialog>
) : null
return createPortal(component, portalElement)
}
ExpandRichInputDialog.propTypes = {
show: PropTypes.bool,
dialogProps: PropTypes.object,
onCancel: PropTypes.func,
onConfirm: PropTypes.func,
onInputHintDialogClicked: PropTypes.func
}
export default ExpandRichInputDialog
@@ -44,7 +44,13 @@ const ExportAsTemplateDialog = ({ show, dialogProps, onCancel }) => {
useEffect(() => {
if (dialogProps.chatflow) {
setName(dialogProps.chatflow.name)
setFlowType(dialogProps.chatflow.type === 'MULTIAGENT' ? 'Agentflow' : 'Chatflow')
if (dialogProps.chatflow.type === 'AGENTFLOW') {
setFlowType('AgentflowV2')
} else if (dialogProps.chatflow.type === 'MULTIAGENT') {
setFlowType('Agentflow')
} else if (dialogProps.chatflow.type === 'CHATFLOW') {
setFlowType('Chatflow')
}
}
if (dialogProps.tool) {
@@ -1,11 +1,6 @@
import { createPortal } from 'react-dom'
import PropTypes from 'prop-types'
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { CodeBlock } from '@/ui-component/markdown/CodeBlock'
import { Dialog, DialogContent, DialogTitle } from '@mui/material'
const InputHintDialog = ({ show, dialogProps, onCancel }) => {
@@ -24,29 +19,7 @@ const InputHintDialog = ({ show, dialogProps, onCancel }) => {
{dialogProps.label}
</DialogTitle>
<DialogContent>
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
isDialog={true}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{dialogProps?.value}
</MemoizedReactMarkdown>
<MemoizedReactMarkdown>{dialogProps?.value}</MemoizedReactMarkdown>
</DialogContent>
</Dialog>
) : null
@@ -7,10 +7,11 @@ import PropTypes from 'prop-types'
import { Button, Dialog, DialogContent, DialogTitle } from '@mui/material'
import { TableViewOnly } from '@/ui-component/table/Table'
import { IconBook2 } from '@tabler/icons-react'
import { useTheme } from '@mui/material/styles'
// Store
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
import { baseURL } from '@/store/constant'
import { baseURL, AGENTFLOW_ICONS } from '@/store/constant'
// API
import configApi from '@/api/config'
@@ -19,9 +20,17 @@ import useApi from '@/hooks/useApi'
const NodeInfoDialog = ({ show, dialogProps, onCancel }) => {
const portalElement = document.getElementById('portal')
const dispatch = useDispatch()
const theme = useTheme()
const getNodeConfigApi = useApi(configApi.getNodeConfig)
const renderIcon = (node) => {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (!foundIcon) return null
return <foundIcon.icon size={24} color={'white'} />
}
useEffect(() => {
if (dialogProps.data) {
getNodeConfigApi.request(dialogProps.data)
@@ -48,27 +57,46 @@ const NodeInfoDialog = ({ show, dialogProps, onCancel }) => {
<DialogTitle sx={{ fontSize: '1rem' }} id='alert-dialog-title'>
{dialogProps.data && dialogProps.data.name && dialogProps.data.label && (
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<div
style={{
width: 50,
height: 50,
marginRight: 10,
borderRadius: '50%',
backgroundColor: 'white'
}}
>
<img
{dialogProps.data.color && !dialogProps.data.icon ? (
<div
style={{
width: '100%',
height: '100%',
padding: 7,
borderRadius: '50%',
objectFit: 'contain'
...theme.typography.commonAvatar,
...theme.typography.largeAvatar,
borderRadius: '15px',
backgroundColor: dialogProps.data.color,
cursor: 'grab',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: dialogProps.data.color,
marginRight: 10
}}
alt={dialogProps.data.name}
src={`${baseURL}/api/v1/node-icon/${dialogProps.data.name}`}
/>
</div>
>
{renderIcon(dialogProps.data)}
</div>
) : (
<div
style={{
width: 50,
height: 50,
marginRight: 10,
borderRadius: '50%',
backgroundColor: 'white'
}}
>
<img
style={{
width: '100%',
height: '100%',
padding: 7,
borderRadius: '50%',
objectFit: 'contain'
}}
alt={dialogProps.data.name}
src={`${baseURL}/api/v1/node-icon/${dialogProps.data.name}`}
/>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', marginLeft: 10 }}>
{dialogProps.data.label}
<div style={{ display: 'flex', flexDirection: 'row' }}>
@@ -2,12 +2,6 @@ import { createPortal } from 'react-dom'
import { useState, useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
// MUI
import {
Box,
@@ -44,7 +38,6 @@ import { styled } from '@mui/material/styles'
//Project Import
import { StyledButton } from '@/ui-component/button/StyledButton'
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { CodeBlock } from '@/ui-component/markdown/CodeBlock'
import promptEmptySVG from '@/assets/images/prompt_empty.svg'
import useApi from '@/hooks/useApi'
@@ -536,30 +529,7 @@ const PromptLangsmithHubDialog = ({ promptType, show, onCancel, onSubmit }) => {
}
}}
>
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
key={Math.random()}
isDialog={true}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{selectedPrompt?.readme}
</MemoizedReactMarkdown>
<MemoizedReactMarkdown>{selectedPrompt?.readme}</MemoizedReactMarkdown>
</div>
</AccordionDetails>
</Accordion>
@@ -3,10 +3,6 @@ import { useDispatch, useSelector } from 'react-redux'
import { useState, useEffect, forwardRef } from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import axios from 'axios'
import { cloneDeep } from 'lodash'
@@ -42,7 +38,6 @@ import { IconTool, IconDeviceSdCard, IconFileExport, IconEraser, IconX, IconDown
// Project import
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { CodeBlock } from '@/ui-component/markdown/CodeBlock'
import SourceDocDialog from '@/ui-component/dialog/SourceDocDialog'
import { MultiDropdown } from '@/ui-component/dropdown/MultiDropdown'
import { StyledButton } from '@/ui-component/button/StyledButton'
@@ -806,33 +801,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
</div>
)
} else {
return (
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
key={Math.random()}
chatflowid={dialogProps.chatflow.id}
isDialog={true}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{item.data}
</MemoizedReactMarkdown>
)
return <MemoizedReactMarkdown chatflowid={dialogProps.chatflow.id}>{item.data}</MemoizedReactMarkdown>
}
}
@@ -1304,44 +1273,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
)}
{agent.messages.length > 0 && (
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({
inline,
className,
children,
...props
}) {
const match = /language-(\w+)/.exec(
className || ''
)
return !inline ? (
<CodeBlock
key={Math.random()}
chatflowid={
dialogProps.chatflow.id
}
isDialog={true}
language={
(match && match[1]) ||
''
}
value={String(
children
).replace(/\n$/, '')}
{...props}
/>
) : (
<code
className={className}
{...props}
>
{children}
</code>
)
}
}}
chatflowid={dialogProps.chatflow.id}
>
{agent.messages.length > 1
? agent.messages.join('\\n')
@@ -1468,30 +1400,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
</div>
)}
<div className='markdownanswer'>
{/* Messages are being rendered in Markdown format */}
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
key={Math.random()}
chatflowid={dialogProps.chatflow.id}
isDialog={true}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
<MemoizedReactMarkdown chatflowid={dialogProps.chatflow.id}>
{message.message}
</MemoizedReactMarkdown>
</div>
@@ -1,4 +1,4 @@
import { useState, useEffect, Fragment } from 'react'
import { useState, useEffect, useContext, Fragment } from 'react'
import { useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import axios from 'axios'
@@ -6,13 +6,15 @@ import axios from 'axios'
// Material
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
import { Popper, CircularProgress, TextField, Box, Typography } from '@mui/material'
import { styled } from '@mui/material/styles'
import { useTheme, styled } from '@mui/material/styles'
// API
import credentialsApi from '@/api/credentials'
// const
import { baseURL } from '@/store/constant'
import { flowContext } from '@/store/context/ReactFlowContext'
import { getAvailableNodesForVariable } from '@/utils/genericHelper'
const StyledPopper = styled(Popper)({
boxShadow: '0px 8px 10px -5px rgb(0 0 0 / 20%), 0px 16px 24px 2px rgb(0 0 0 / 14%), 0px 6px 30px 5px rgb(0 0 0 / 12%)',
@@ -26,15 +28,16 @@ const StyledPopper = styled(Popper)({
}
})
const fetchList = async ({ name, nodeData }) => {
const loadMethod = nodeData.inputParams.find((param) => param.name === name)?.loadMethod
const fetchList = async ({ name, nodeData, previousNodes, currentNode }) => {
const selectedParam = nodeData.inputParams.find((param) => param.name === name)
const loadMethod = selectedParam?.loadMethod
const username = localStorage.getItem('username')
const password = localStorage.getItem('password')
let lists = await axios
.post(
`${baseURL}/api/v1/node-load-method/${nodeData.name}`,
{ ...nodeData, loadMethod },
{ ...nodeData, loadMethod, previousNodes, currentNode },
{
auth: username && password ? { username, password } : undefined,
headers: { 'Content-type': 'application/json', 'x-request-from': 'internal' }
@@ -63,6 +66,7 @@ export const AsyncDropdown = ({
multiple = false
}) => {
const customization = useSelector((state) => state.customization)
const theme = useTheme()
const [open, setOpen] = useState(false)
const [options, setOptions] = useState([])
@@ -82,6 +86,7 @@ export const AsyncDropdown = ({
const getDefaultOptionValue = () => (multiple ? [] : '')
const addNewOption = [{ label: '- Create New -', name: '-create-' }]
let [internalValue, setInternalValue] = useState(value ?? 'choose an option')
const { reactFlowInstance } = useContext(flowContext)
const fetchCredentialList = async () => {
try {
@@ -112,7 +117,45 @@ export const AsyncDropdown = ({
setLoading(true)
;(async () => {
const fetchData = async () => {
let response = credentialNames.length ? await fetchCredentialList() : await fetchList({ name, nodeData })
let response = []
if (credentialNames.length) {
response = await fetchCredentialList()
} else {
const body = {
name,
nodeData
}
if (reactFlowInstance) {
const previousNodes = getAvailableNodesForVariable(
reactFlowInstance.getNodes(),
reactFlowInstance.getEdges(),
nodeData.id,
`${nodeData.id}-input-${name}-${nodeData.inputParams.find((param) => param.name === name)?.type || ''}`,
true
).map((node) => ({ id: node.id, name: node.data.name, label: node.data.label, inputs: node.data.inputs }))
let currentNode = reactFlowInstance.getNodes().find((node) => node.id === nodeData.id)
if (currentNode) {
currentNode = {
id: currentNode.id,
name: currentNode.data.name,
label: currentNode.data.label,
inputs: currentNode.data.inputs
}
body.currentNode = currentNode
}
body.previousNodes = previousNodes
}
response = await fetchList(body)
}
for (let j = 0; j < response.length; j += 1) {
if (response[j].imageSrc) {
const imageSrc = `${baseURL}/api/v1/node-icon/${response[j].name}`
response[j].imageSrc = imageSrc
}
}
if (isCreateNewOption) setOptions([...response, ...addNewOption])
else setOptions([...response])
setLoading(false)
@@ -164,24 +207,70 @@ export const AsyncDropdown = ({
}}
PopperComponent={StyledPopper}
loading={loading}
renderInput={(params) => (
<TextField
{...params}
value={internalValue}
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{loading ? <CircularProgress color='inherit' size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}}
sx={{ height: '100%', '& .MuiInputBase-root': { height: '100%' } }}
/>
)}
renderInput={(params) => {
const matchingOptions = multiple
? findMatchingOptions(options, internalValue)
: [findMatchingOptions(options, internalValue)].filter(Boolean)
return (
<TextField
{...params}
value={internalValue}
sx={{
height: '100%',
'& .MuiInputBase-root': {
height: '100%',
'& fieldset': {
borderColor: theme.palette.grey[900] + 25
}
}
}}
InputProps={{
...params.InputProps,
startAdornment: (
<>
{matchingOptions.map((option) =>
option?.imageSrc ? (
<Box
key={option.name}
component='img'
src={option.imageSrc}
alt={option.label || 'Selected Option'}
sx={{
width: 32,
height: 32,
borderRadius: '50%',
marginRight: 0.5
}}
/>
) : null
)}
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<Fragment>
{loading ? <CircularProgress color='inherit' size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
}}
/>
)
}}
renderOption={(props, option) => (
<Box component='li' {...props}>
<Box component='li' {...props} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{option.imageSrc && (
<img
src={option.imageSrc}
alt={option.description}
style={{
width: 30,
height: 30,
padding: 1,
borderRadius: '50%'
}}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5'>{option.label}</Typography>
{option.description && (
@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'
import { Popper, FormControl, TextField, Box, Typography } from '@mui/material'
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
import { styled } from '@mui/material/styles'
import { useTheme, styled } from '@mui/material/styles'
import PropTypes from 'prop-types'
const StyledPopper = styled(Popper)({
@@ -23,6 +23,7 @@ export const Dropdown = ({ name, value, loading, options, onSelect, disabled = f
const findMatchingOptions = (options = [], value) => options.find((option) => option.name === value)
const getDefaultOptionValue = () => ''
let [internalValue, setInternalValue] = useState(value ?? 'choose an option')
const theme = useTheme()
return (
<FormControl sx={{ mt: 1, width: '100%' }} size='small'>
@@ -49,7 +50,12 @@ export const Dropdown = ({ name, value, loading, options, onSelect, disabled = f
value={internalValue}
sx={{
height: '100%',
'& .MuiInputBase-root': { height: '100%' }
'& .MuiInputBase-root': {
height: '100%',
'& fieldset': {
borderColor: theme.palette.grey[900] + 25
}
}
}}
InputProps={{
...params.InputProps,
@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'
import { Popper, FormControl, TextField, Box, Typography } from '@mui/material'
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
import { styled } from '@mui/material/styles'
import { useTheme, styled } from '@mui/material/styles'
import PropTypes from 'prop-types'
const StyledPopper = styled(Popper)({
@@ -28,6 +28,7 @@ export const MultiDropdown = ({ name, value, options, onSelect, formControlSx =
}
const getDefaultOptionValue = () => []
let [internalValue, setInternalValue] = useState(value ?? [])
const theme = useTheme()
return (
<FormControl sx={{ mt: 1, width: '100%', ...formControlSx }} size='small'>
@@ -54,7 +55,19 @@ export const MultiDropdown = ({ name, value, options, onSelect, formControlSx =
}}
PopperComponent={StyledPopper}
renderInput={(params) => (
<TextField {...params} value={internalValue} sx={{ height: '100%', '& .MuiInputBase-root': { height: '100%' } }} />
<TextField
{...params}
value={internalValue}
sx={{
height: '100%',
'& .MuiInputBase-root': {
height: '100%',
'& fieldset': {
borderColor: theme.palette.grey[900] + 25
}
}
}}
/>
)}
renderOption={(props, option) => (
<Box component='li' {...props}>
@@ -31,7 +31,7 @@ export const CodeEditor = ({
'.cm-content':
lang !== 'js'
? {
fontFamily: 'Roboto, sans-serif',
fontFamily: `'Inter', 'Roboto', 'Arial', sans-serif`,
fontSize: '0.95rem',
letterSpacing: '0em',
fontWeight: 400,
@@ -47,11 +47,7 @@ export const CodeEditor = ({
value={value}
height={height ?? 'calc(100vh - 220px)'}
theme={theme === 'dark' ? (lang === 'js' ? vscodeDark : sublime) : 'none'}
extensions={
lang === 'js'
? [javascript({ jsx: true }), EditorView.lineWrapping, customStyle]
: [json(), EditorView.lineWrapping, customStyle]
}
extensions={[lang === 'js' ? javascript({ jsx: true }) : json(), EditorView.lineWrapping, customStyle]}
onChange={onValueChange}
readOnly={disabled}
editable={!disabled}
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types'
import { Box, Button, FormControl, ListItem, ListItemAvatar, ListItemText, MenuItem, Select, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useTheme } from '@mui/material/styles'
// Project Imports
import { StyledButton } from '@/ui-component/button/StyledButton'
@@ -310,6 +311,7 @@ const FollowUpPrompts = ({ dialogProps }) => {
const dispatch = useDispatch()
useNotifier()
const theme = useTheme()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
@@ -473,7 +475,16 @@ const FollowUpPrompts = ({ dialogProps }) => {
<>
<Typography variant='h5'>Providers</Typography>
<FormControl fullWidth>
<Select size='small' value={selectedProvider} onChange={handleSelectedProviderChange}>
<Select
size='small'
value={selectedProvider}
onChange={handleSelectedProviderChange}
sx={{
'& .MuiSvgIcon-root': {
color: theme?.customization?.isDarkMode ? '#fff' : 'inherit'
}
}}
>
{Object.values(followUpPromptsOptions).map((provider) => (
<MenuItem key={provider.name} value={provider.name}>
{provider.label}
@@ -1,5 +1,5 @@
import logo from '@/assets/images/flowise_logo.png'
import logoDark from '@/assets/images/flowise_logo_dark.png'
import logo from '@/assets/images/flowise_white.svg'
import logoDark from '@/assets/images/flowise_dark.svg'
import { useSelector } from 'react-redux'
@@ -9,7 +9,7 @@ const Logo = () => {
const customization = useSelector((state) => state.customization)
return (
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'row' }}>
<div style={{ alignItems: 'center', display: 'flex', flexDirection: 'row', marginLeft: '10px' }}>
<img
style={{ objectFit: 'contain', height: 'auto', width: 150 }}
src={customization.isDarkMode ? logoDark : logo}
@@ -43,6 +43,38 @@ const OverrideConfigTable = ({ columns, onToggle, rows, sx }) => {
onToggle(row, enabled)
}
const renderCellContent = (key, row) => {
if (key === 'enabled') {
return <SwitchInput onChange={(enabled) => handleChange(enabled, row)} value={row.enabled} />
} else if (key === 'type' && row.schema) {
// If there's schema information, add a tooltip
const schemaContent =
'[<br>' +
row.schema
.map(
(item) =>
`&nbsp;&nbsp;${JSON.stringify(
{
[item.name]: item.type
},
null,
2
)}`
)
.join(',<br>') +
'<br>]'
return (
<Stack direction='row' alignItems='center' spacing={1}>
<Typography>{row[key]}</Typography>
<TooltipWithParser title={`<div>Schema:<br/>${schemaContent}</div>`} />
</Stack>
)
} else {
return row[key]
}
}
return (
<TableContainer component={Paper}>
<Table size='small' sx={{ minWidth: 650, ...sx }} aria-label='simple table'>
@@ -57,16 +89,8 @@ const OverrideConfigTable = ({ columns, onToggle, rows, sx }) => {
{rows.map((row, index) => (
<TableRow key={index} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
{Object.keys(row).map((key, index) => {
if (key !== 'id') {
return (
<TableCell key={index}>
{key === 'enabled' ? (
<SwitchInput onChange={(enabled) => handleChange(enabled, row)} value={row.enabled} />
) : (
row[key]
)}
</TableCell>
)
if (key !== 'id' && key !== 'schema') {
return <TableCell key={index}>{renderCellContent(key, row)}</TableCell>
}
})}
</TableRow>
@@ -169,7 +193,7 @@ const OverrideConfig = ({ dialogProps }) => {
const seenNodes = new Set()
nodes.forEach((item) => {
const { node, nodeId, label, name, type } = item
const { node, nodeId, label, name, type, schema } = item
seenNodes.add(node)
if (!result[node]) {
@@ -186,7 +210,7 @@ const OverrideConfig = ({ dialogProps }) => {
if (!result[node].nodeIds.includes(nodeId)) result[node].nodeIds.push(nodeId)
const param = { label, name, type }
const param = { label, name, type, schema }
if (!result[node].params.some((existingParam) => JSON.stringify(existingParam) === JSON.stringify(param))) {
result[node].params.push(param)
@@ -395,7 +419,9 @@ const OverrideConfig = ({ dialogProps }) => {
rows={nodeOverrides[nodeLabel]}
columns={
nodeOverrides[nodeLabel].length > 0
? Object.keys(nodeOverrides[nodeLabel][0])
? Object.keys(nodeOverrides[nodeLabel][0]).filter(
(key) => key !== 'schema' && key !== 'id'
)
: []
}
onToggle={(property, status) =>
@@ -6,6 +6,7 @@ import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackba
// material-ui
import { Typography, Box, Button, FormControl, ListItem, ListItemAvatar, ListItemText, MenuItem, Select } from '@mui/material'
import { IconX } from '@tabler/icons-react'
import { useTheme } from '@mui/material/styles'
// Project import
import CredentialInputHandler from '@/views/canvas/CredentialInputHandler'
@@ -242,6 +243,7 @@ const SpeechToText = ({ dialogProps }) => {
const dispatch = useDispatch()
useNotifier()
const theme = useTheme()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
@@ -349,7 +351,16 @@ const SpeechToText = ({ dialogProps }) => {
<Box fullWidth sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography>Providers</Typography>
<FormControl fullWidth>
<Select size='small' value={selectedProvider} onChange={handleProviderChange}>
<Select
size='small'
value={selectedProvider}
onChange={handleProviderChange}
sx={{
'& .MuiSvgIcon-root': {
color: theme?.customization?.isDarkMode ? '#fff' : 'inherit'
}
}}
>
<MenuItem value='none'>None</MenuItem>
{Object.values(speechToTextProviders).map((provider) => (
<MenuItem key={provider.name} value={provider.name}>
+8 -1
View File
@@ -1,10 +1,12 @@
import { useState, useEffect, useRef } from 'react'
import PropTypes from 'prop-types'
import { FormControl, OutlinedInput, InputBase, Popover } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import SelectVariable from '@/ui-component/json/SelectVariable'
import { getAvailableNodesForVariable } from '@/utils/genericHelper'
export const Input = ({ inputParam, value, nodes, edges, nodeId, onChange, disabled = false }) => {
const theme = useTheme()
const [myValue, setMyValue] = useState(value ?? '')
const [anchorEl, setAnchorEl] = useState(null)
const [availableNodesForVariable, setAvailableNodesForVariable] = useState([])
@@ -71,7 +73,7 @@ export const Input = ({ inputParam, value, nodes, edges, nodeId, onChange, disab
style: {
border: 'none',
background: 'none',
color: '#212121'
color: 'inherit'
}
}}
sx={{
@@ -108,6 +110,11 @@ export const Input = ({ inputParam, value, nodes, edges, nodeId, onChange, disab
height: inputParam.rows ? '90px' : 'inherit'
}
}}
sx={{
'& .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.grey[900] + 25
}
}}
/>
</FormControl>
)}
@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useEditor, EditorContent } from '@tiptap/react'
import Placeholder from '@tiptap/extension-placeholder'
import { mergeAttributes } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { styled } from '@mui/material/styles'
import { Box } from '@mui/material'
import Mention from '@tiptap/extension-mention'
import { suggestionOptions } from './suggestionOption'
import { getAvailableNodesForVariable } from '@/utils/genericHelper'
// define your extension array
const extensions = (availableNodesForVariable, availableState, acceptNodeOutputAsVariable, nodes, nodeData, isNodeInsideInteration) => [
StarterKit,
Mention.configure({
HTMLAttributes: {
class: 'variable'
},
renderHTML({ options, node }) {
return [
'span',
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`${options.suggestion.char} ${node.attrs.label ?? node.attrs.id} }}`
]
},
suggestion: suggestionOptions(
availableNodesForVariable,
availableState,
acceptNodeOutputAsVariable,
nodes,
nodeData,
isNodeInsideInteration
),
deleteTriggerWithBackspace: true
})
]
// Add styled component for editor wrapper
const StyledEditorContent = styled(EditorContent)(({ theme, rows }) => ({
'& .ProseMirror': {
padding: '0px 14px',
height: rows ? `${rows * 1.4375}rem` : '2.4rem',
overflowY: rows ? 'auto' : 'hidden',
overflowX: rows ? 'auto' : 'hidden',
lineHeight: rows ? '1.4375em' : '0.875em',
fontWeight: 500,
color: theme.palette.grey[900],
border: `1px solid ${theme.palette.grey[900] + 25}`,
borderRadius: '10px',
backgroundColor: theme.palette.textBackground.main,
boxSizing: 'border-box',
whiteSpace: rows ? 'pre-wrap' : 'nowrap',
'&:hover': {
borderColor: theme.palette.text.primary,
cursor: 'text'
},
'&:focus': {
borderColor: theme.palette.primary.main,
outline: 'none'
},
'&[disabled]': {
backgroundColor: theme.palette.action.disabledBackground,
color: theme.palette.action.disabled
},
// Placeholder for first paragraph when editor is empty
'& p.is-editor-empty:first-of-type::before': {
content: 'attr(data-placeholder)',
float: 'left',
color: theme.palette.text.primary,
opacity: 0.4,
pointerEvents: 'none',
height: 0
}
}
}))
export const RichInput = ({ inputParam, value, nodes, edges, nodeId, onChange, disabled = false }) => {
const [availableNodesForVariable, setAvailableNodesForVariable] = useState([])
const [availableState, setAvailableState] = useState([])
const [nodeData, setNodeData] = useState({})
const [isNodeInsideInteration, setIsNodeInsideInteration] = useState(false)
useEffect(() => {
if (!disabled && nodes && edges && nodeId && inputParam) {
const nodesForVariable = inputParam?.acceptVariable ? getAvailableNodesForVariable(nodes, edges, nodeId, inputParam.id) : []
setAvailableNodesForVariable(nodesForVariable)
const startAgentflowNode = nodes.find((node) => node.data.name === 'startAgentflow')
const state = startAgentflowNode?.data?.inputs?.startState
setAvailableState(state)
const agentflowNode = nodes.find((node) => node.data.id === nodeId)
setNodeData(agentflowNode?.data)
setIsNodeInsideInteration(nodes.find((node) => node.data.id === nodeId)?.extent === 'parent')
}
}, [disabled, inputParam, nodes, edges, nodeId])
const editor = useEditor(
{
extensions: [
...extensions(
availableNodesForVariable,
availableState,
inputParam?.acceptNodeOutputAsVariable,
nodes,
nodeData,
isNodeInsideInteration
),
Placeholder.configure({ placeholder: inputParam?.placeholder })
],
content: value,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
editable: !disabled
},
[availableNodesForVariable]
)
return (
<Box sx={{ mt: 1, border: '' }}>
<StyledEditorContent editor={editor} rows={inputParam?.rows} />
</Box>
)
}
RichInput.propTypes = {
inputParam: PropTypes.object,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onChange: PropTypes.func,
disabled: PropTypes.bool,
nodes: PropTypes.array,
edges: PropTypes.array,
nodeId: PropTypes.string
}
@@ -0,0 +1,192 @@
import { List, ListItem, ListItemButton, Paper, Typography, Divider } from '@mui/material'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { useSelector } from 'react-redux'
import { useTheme } from '@mui/material/styles'
import PropTypes from 'prop-types'
const SuggestionList = forwardRef((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const customization = useSelector((state) => state.customization)
const theme = useTheme()
useEffect(() => {
// Configure tippy to auto-adjust placement
const tippyOptions = {
placement: 'bottom-start',
flip: true,
flipOnUpdate: true,
// Optional: you can add an offset to give some spacing
offset: [0, 8]
}
// Update tippy instance with new options
if (props.tippyInstance) {
Object.assign(props.tippyInstance, tippyOptions)
}
}, [props.tippyInstance])
const selectItem = (index) => {
if (index >= props.items.length) {
// Make sure we actually have enough items to select the given index. For
// instance, if a user presses "Enter" when there are no options, the index will
// be 0 but there won't be any items, so just ignore the callback here
return
}
const suggestion = props.items[index]
// Set all of the attributes of our Mention node based on the suggestion
// data. The fields of `suggestion` will depend on whatever data you
// return from your `items` function in your "suggestion" options handler.
// Our suggestion handler returns `MentionSuggestion`s (which we've
// indicated via SuggestionProps<MentionSuggestion>). We are passing an
// object of the `MentionNodeAttrs` shape when calling `command` (utilized
// by the Mention extension to create a Mention Node).
const mentionItem = {
id: suggestion.id,
label: suggestion.mentionLabel
}
// @ts-expect-error there is currently a bug in the Tiptap SuggestionProps
// type where if you specify the suggestion type (like
// `SuggestionProps<MentionSuggestion>`), it will incorrectly require that
// type variable for `command`'s argument as well (whereas instead the
// type of that argument should be the Mention Node attributes). This
// should be fixed once https://github.com/ueberdosis/tiptap/pull/4136 is
// merged and we can add a separate type arg to `SuggestionProps` to
// specify the type of the commanded selected item.
props.command(mentionItem)
}
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
}))
// Group items by category
const groupedItems = props.items.reduce((acc, item) => {
const category = item.category || 'Other'
if (!acc[category]) {
acc[category] = []
}
acc[category].push(item)
return acc
}, {})
return props.items.length > 0 ? (
<Paper
elevation={5}
sx={{
maxHeight: '300px',
overflowY: 'auto'
}}
>
<List
dense
sx={{
overflow: 'hidden',
maxWidth: '300px'
}}
>
{Object.entries(groupedItems).map(([category, items], categoryIndex) => (
<div key={category}>
{/* Add divider before each category except the first one */}
{categoryIndex > 0 && <Divider />}
{/* Category header */}
<ListItem sx={{ py: 0.5, bgcolor: customization.isDarkMode ? theme.palette.common.black : theme.palette.grey[50] }}>
<Typography variant='overline' color='text.secondary'>
{category}
</Typography>
</ListItem>
{/* Category items */}
{items.map((item) => {
const itemIndex = props.items.findIndex((i) => i.id === item.id)
return (
<ListItem key={item.id} disablePadding>
<ListItemButton
selected={itemIndex === selectedIndex}
onClick={() => selectItem(itemIndex)}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant='body1' sx={{ fontWeight: 500 }}>
{item.label || item.mentionLabel}
</Typography>
{item.description && (
<Typography
variant='caption'
color='text.secondary'
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{item.description}
</Typography>
)}
</ListItemButton>
</ListItem>
)
})}
</div>
))}
</List>
</Paper>
) : null
})
SuggestionList.displayName = 'SuggestionList'
// Add PropTypes validation
SuggestionList.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
mentionLabel: PropTypes.string.isRequired,
label: PropTypes.string,
description: PropTypes.string,
category: PropTypes.string
})
).isRequired,
command: PropTypes.func.isRequired,
tippyInstance: PropTypes.object
}
export default SuggestionList
@@ -0,0 +1,223 @@
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import SuggestionList from './SuggestionList'
import variablesApi from '@/api/variables'
/**
* Workaround for the current typing incompatibility between Tippy.js and Tiptap
* Suggestion utility.
*
* @see https://github.com/ueberdosis/tiptap/issues/2795#issuecomment-1160623792
*
* Adopted from
* https://github.com/Doist/typist/blob/a1726a6be089e3e1452def641dfcfc622ac3e942/stories/typist-editor/constants/suggestions.ts#L169-L186
*/
const DOM_RECT_FALLBACK = {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0,
toJSON() {
return {}
}
}
// Cache for storing variables
let cachedVariables = []
// Function to fetch variables
const fetchVariables = async () => {
try {
const response = await variablesApi.getAllVariables()
cachedVariables = response.data || []
return cachedVariables
} catch (error) {
console.error('Failed to fetch variables:', error)
return []
}
}
export const suggestionOptions = (
availableNodesForVariable,
availableState,
acceptNodeOutputAsVariable,
nodes,
nodeData,
isNodeInsideInteration
) => ({
char: '{{',
items: async ({ query }) => {
const defaultItems = [
{ id: 'question', mentionLabel: 'question', description: "User's question from chatbox", category: 'Chat Context' },
{
id: 'chat_history',
mentionLabel: 'chat_history',
description: 'Past conversation history between user and AI',
category: 'Chat Context'
},
{
id: 'runtime_messages_length',
mentionLabel: 'runtime_messages_length',
description: 'Total messsages between LLM and Agent',
category: 'Chat Context'
},
{
id: 'file_attachment',
mentionLabel: 'file_attachment',
description: 'Files uploaded from the chat',
category: 'Chat Context'
},
{ id: '$flow.sessionId', mentionLabel: '$flow.sessionId', description: 'Current session ID', category: 'Flow Variables' },
{ id: '$flow.chatId', mentionLabel: '$flow.chatId', description: 'Current chat ID', category: 'Flow Variables' },
{ id: '$flow.chatflowId', mentionLabel: '$flow.chatflowId', description: 'Current chatflow ID', category: 'Flow Variables' }
]
const stateItems = (availableState || []).map((state) => ({
id: `$flow.state.${state.key}`,
mentionLabel: `$flow.state.${state.key}`,
category: 'Flow State'
}))
if (isNodeInsideInteration) {
defaultItems.unshift({
id: '$iteration',
mentionLabel: '$iteration',
description: 'Iteration item. For JSON, use dot notation: $iteration.name',
category: 'Iteration'
})
}
// Add output option if acceptNodeOutputAsVariable is true
if (acceptNodeOutputAsVariable) {
defaultItems.unshift({
id: 'output',
mentionLabel: 'output',
description: 'Output from the current node',
category: 'Node Outputs'
})
const structuredOutputs = nodeData?.inputs?.llmStructuredOutput ?? []
if (structuredOutputs && structuredOutputs.length > 0) {
structuredOutputs.forEach((item) => {
defaultItems.unshift({
id: `output.${item.key}`,
mentionLabel: `output.${item.key}`,
description: `${item.description}`,
category: 'Node Outputs'
})
})
}
}
// Fetch variables if cache is empty
if (cachedVariables.length === 0) {
await fetchVariables()
}
const variableItems = cachedVariables.map((variable) => ({
id: `$vars.${variable.name}`,
mentionLabel: `$vars.${variable.name}`,
description: `Variable: ${variable.value} (${variable.type})`,
category: 'Custom Variables'
}))
const startAgentflowNode = nodes.find((node) => node.data.name === 'startAgentflow')
const formInputTypes = startAgentflowNode?.data?.inputs?.formInputTypes
let formItems = []
if (formInputTypes) {
formItems = (formInputTypes || []).map((input) => ({
id: `$form.${input.name}`,
mentionLabel: `$form.${input.name}`,
description: `Form Input: ${input.label}`,
category: 'Form Inputs'
}))
}
const nodeItems = (availableNodesForVariable || []).map((node) => {
const selectedOutputAnchor = node.data.outputAnchors?.[0]?.options?.find((ancr) => ancr.name === node.data.outputs['output'])
return {
id: `${node.id}`,
mentionLabel: node.data.inputs.chainName ?? node.data.inputs.functionName ?? node.data.inputs.variableName ?? node.data.id,
description:
node.data.name === 'ifElseFunction'
? node.data.description
: `${selectedOutputAnchor?.label ?? 'Output'} from ${node.data.label}`,
category: 'Node Outputs'
}
})
const allItems = [...defaultItems, ...formItems, ...nodeItems, ...stateItems, ...variableItems]
return allItems.filter(
(item) => item.mentionLabel.toLowerCase().includes(query.toLowerCase()) || item.id.toLowerCase().includes(query.toLowerCase())
)
},
render: () => {
let component
let popup
return {
onStart: (props) => {
component = new ReactRenderer(SuggestionList, {
props,
editor: props.editor
})
popup = tippy('body', {
getReferenceClientRect: () => props.clientRect?.() ?? DOM_RECT_FALLBACK,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start'
})[0]
},
onUpdate(props) {
component?.updateProps(props)
popup?.setProps({
getReferenceClientRect: () => props.clientRect?.() ?? DOM_RECT_FALLBACK
})
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup?.hide()
return true
}
if (!component?.ref) {
return false
}
return component.ref.onKeyDown(props)
},
onExit() {
popup?.destroy()
component?.destroy()
// Remove references to the old popup and component upon destruction/exit.
// (This should prevent redundant calls to `popup.destroy()`, which Tippy
// warns in the console is a sign of a memory leak, as the `suggestion`
// plugin seems to call `onExit` both when a suggestion menu is closed after
// a user chooses an option, *and* when the editor itself is destroyed.)
popup = undefined
component = undefined
}
}
}
})
// Export function to refresh variables cache
export const refreshVariablesCache = () => {
return fetchVariables()
}
@@ -69,7 +69,18 @@ export const JsonEditorInput = ({
/>
)}
{!disabled && (
<div key={JSON.stringify(myValue)}>
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation()
}
}}
role='button'
aria-label='JSON Editor'
tabIndex={0}
key={JSON.stringify(myValue)}
>
<ReactJson
theme={isDarkMode ? 'ocean' : 'rjv-default'}
style={{ padding: 10, borderRadius: 10 }}
@@ -0,0 +1,87 @@
import { useSelector } from 'react-redux'
import { Box } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import PropTypes from 'prop-types'
// Syntax highlighting function for JSON
function syntaxHighlight(json) {
if (!json) return '' // No JSON from response
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return json.replace(
// eslint-disable-next-line
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
function (match) {
let cls = 'number'
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key'
} else {
cls = 'string'
}
} else if (/true|false/.test(match)) {
cls = 'boolean'
} else if (/null/.test(match)) {
cls = 'null'
}
return '<span class="' + cls + '">' + match + '</span>'
}
)
}
export const JSONViewer = ({ data, maxHeight = '400px' }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const isDarkMode = customization.isDarkMode
return (
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 2,
backgroundColor: theme.palette.background.default,
width: '100%',
overflow: 'auto',
maxHeight: maxHeight
}}
>
<style>{`
pre .string {
color: ${isDarkMode ? '#9cdcfe' : 'green'};
}
pre .number {
color: ${isDarkMode ? '#b5cea8' : 'darkorange'};
}
pre .boolean {
color: ${isDarkMode ? '#569cd6' : 'blue'};
}
pre .null {
color: ${isDarkMode ? '#d4d4d4' : 'magenta'};
}
pre .key {
color: ${isDarkMode ? '#ff5733' : '#ff5733'};
}
`}</style>
<pre
style={{
margin: 0,
fontFamily: `'Inter', 'Roboto', 'Arial', sans-serif`,
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
dangerouslySetInnerHTML={{
__html: syntaxHighlight(JSON.stringify(data, null, 2), isDarkMode)
}}
/>
</Box>
)
}
JSONViewer.propTypes = {
data: PropTypes.object,
maxHeight: PropTypes.string
}
@@ -32,7 +32,7 @@ const programmingLanguages = {
css: '.css'
}
export const CodeBlock = memo(({ language, chatflowid, isDialog, value }) => {
export const CodeBlock = memo(({ language, chatflowid, isFullWidth, value }) => {
const theme = useTheme()
const [anchorEl, setAnchorEl] = useState(null)
const openPopOver = Boolean(anchorEl)
@@ -76,7 +76,7 @@ export const CodeBlock = memo(({ language, chatflowid, isDialog, value }) => {
}
return (
<div style={{ width: isDialog ? '' : 300 }}>
<div style={{ width: isFullWidth ? '' : 300 }}>
<Box sx={{ color: 'white', background: theme.palette?.common.dark, p: 1, borderTopLeftRadius: 10, borderTopRightRadius: 10 }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
{language}
@@ -118,6 +118,6 @@ CodeBlock.displayName = 'CodeBlock'
CodeBlock.propTypes = {
language: PropTypes.string,
chatflowid: PropTypes.string,
isDialog: PropTypes.bool,
isFullWidth: PropTypes.bool,
value: PropTypes.string
}
@@ -1,19 +1,165 @@
import { memo } from 'react'
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import ReactMarkdown from 'react-markdown'
import './Markdown.css'
import { CodeBlock } from '../markdown/CodeBlock'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
/**
* Checks if text likely contains LaTeX math notation
* @param {string} text - Text to check for LaTeX math
* @param {Object[]} customPatterns - Additional regex patterns to check
* @returns {boolean} - Whether LaTeX math is likely present
*/
const containsLaTeX = (text, customPatterns = []) => {
if (!text || typeof text !== 'string') return false
// Common LaTeX patterns - more permissive to catch edge cases
const defaultPatterns = [
{ regex: /\$\$.+?\$\$/s, name: 'Block math: $$...$$' },
{ regex: /\\\(.+?\\\)/s, name: 'Inline math: \\(...\\)' },
{ regex: /\\\[[\s\S]*?\\\]/, name: 'Display math: \\[...\\]' },
{ regex: /\\begin{(equation|align|gather|math|matrix|bmatrix|pmatrix|vmatrix|cases)}.+?\\end{\1}/s, name: 'Environment math' },
{ regex: /\$(.*?[\\{}_^].*?)\$/, name: 'Inline math with $' },
{ regex: /\\frac/, name: 'LaTeX command: \\frac' },
{ regex: /\\sqrt/, name: 'LaTeX command: \\sqrt' },
{ regex: /\\pm/, name: 'LaTeX command: \\pm' },
{ regex: /\\cdot/, name: 'LaTeX command: \\cdot' },
{ regex: /\\text/, name: 'LaTeX command: \\text' },
{ regex: /\\sum/, name: 'LaTeX command: \\sum' },
{ regex: /\\prod/, name: 'LaTeX command: \\prod' },
{ regex: /\\int/, name: 'LaTeX command: \\int' }
]
// Combine default and custom patterns
const patterns = [...defaultPatterns, ...customPatterns]
for (const pattern of patterns) {
if (pattern.regex.test(text)) {
return true
}
}
return false
}
/**
* Preprocesses text to make LaTeX syntax more compatible with Markdown
* @param {string} text - Original text with potentially problematic LaTeX syntax
* @returns {string} - Text with LaTeX syntax adjusted for better compatibility
*/
const preprocessLatex = (text) => {
if (!text || typeof text !== 'string') return text
// Replace problematic LaTeX patterns with more compatible alternatives
const processedText = text
// Convert display math with indentation to dollar-dollar format
.replace(/(\n\s*)\\\[([\s\S]*?)\\\](\s*\n|$)/g, (match, before, content, after) => {
// Preserve indentation but use $$ format which is more reliably handled
return `${before}$$${content}$$${after}`
})
// Convert inline math to dollar format with spaces to avoid conflicts
.replace(/\\\(([\s\S]*?)\\\)/g, '$ $1 $')
return processedText
}
/**
* Enhanced Markdown component with memoization for better performance
* Supports various plugins and custom rendering components
*/
export const MemoizedReactMarkdown = memo(
({ children, ...props }) => (
<div className='react-markdown'>
<ReactMarkdown {...props}>{children}</ReactMarkdown>
</div>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
({ children, ...props }) => {
// Preprocess text to improve LaTeX compatibility
const processedChildren = useMemo(() => (typeof children === 'string' ? preprocessLatex(children) : children), [children])
// Enable math by default unless explicitly disabled
const shouldEnableMath = useMemo(() => {
const hasLatex = processedChildren && containsLaTeX(processedChildren, props.mathPatterns || [])
return props.disableMath === true ? false : props.forceMath || hasLatex
}, [processedChildren, props.forceMath, props.disableMath, props.mathPatterns])
// Configure plugins based on content
const remarkPlugins = useMemo(() => {
if (props.remarkPlugins) return props.remarkPlugins
return shouldEnableMath ? [remarkGfm, remarkMath] : [remarkGfm]
}, [props.remarkPlugins, shouldEnableMath])
const rehypePlugins = useMemo(() => {
if (props.rehypePlugins) return props.rehypePlugins
return shouldEnableMath ? [rehypeMathjax, rehypeRaw] : [rehypeRaw]
}, [props.rehypePlugins, shouldEnableMath])
return (
<div className='react-markdown'>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={{
code({ inline, className, children, ...codeProps }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
key={Math.random()}
chatflowid={props.chatflowid}
isFullWidth={props.isFullWidth !== undefined ? props.isFullWidth : true}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...codeProps}
/>
) : (
<code className={className} {...codeProps}>
{children}
</code>
)
},
p({ children }) {
return <p style={{ whiteSpace: 'pre-line' }}>{children}</p>
},
...props.components
}}
{...props}
>
{processedChildren}
</ReactMarkdown>
</div>
)
},
(prevProps, nextProps) => {
// More detailed comparison for better memoization
if (prevProps.children !== nextProps.children) return false
// Check if other props have changed
const prevEntries = Object.entries(prevProps).filter(([key]) => key !== 'children')
const nextEntries = Object.entries(nextProps).filter(([key]) => key !== 'children')
if (prevEntries.length !== nextEntries.length) return false
// Simple shallow comparison of remaining props
for (const [key, value] of prevEntries) {
if (key === 'components' || key === 'remarkPlugins' || key === 'rehypePlugins') continue // Skip complex objects
if (nextProps[key] !== value) return false
}
return true
}
)
MemoizedReactMarkdown.displayName = 'MemoizedReactMarkdown'
MemoizedReactMarkdown.propTypes = {
children: PropTypes.any
children: PropTypes.any,
chatflowid: PropTypes.string,
isFullWidth: PropTypes.bool,
remarkPlugins: PropTypes.array,
rehypePlugins: PropTypes.array,
components: PropTypes.object,
forceMath: PropTypes.bool,
disableMath: PropTypes.bool,
mathPatterns: PropTypes.array
}
@@ -0,0 +1,306 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import moment from 'moment'
import { styled } from '@mui/material/styles'
import {
Box,
Paper,
Skeleton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
useTheme,
Checkbox
} from '@mui/material'
import { tableCellClasses } from '@mui/material/TableCell'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import ErrorIcon from '@mui/icons-material/Error'
import { IconLoader, IconCircleXFilled } from '@tabler/icons-react'
const StyledTableCell = styled(TableCell)(({ theme }) => ({
borderColor: theme.palette.grey[900] + 25,
[`&.${tableCellClasses.head}`]: {
color: theme.palette.grey[900]
},
[`&.${tableCellClasses.body}`]: {
fontSize: 14,
height: 64
}
}))
const StyledTableRow = styled(TableRow)(() => ({
// hide last border
'&:last-child td, &:last-child th': {
border: 0
}
}))
const getIconFromStatus = (state, theme) => {
switch (state) {
case 'FINISHED':
return CheckCircleIcon
case 'ERROR':
case 'TIMEOUT':
return ErrorIcon
case 'TERMINATED':
// eslint-disable-next-line react/display-name
return (props) => {
const IconWrapper = (props) => <IconCircleXFilled {...props} color={theme.palette.error.main} />
IconWrapper.displayName = 'TerminatedIcon'
return <IconWrapper {...props} />
}
case 'STOPPED':
return StopCircleIcon
case 'INPROGRESS':
// eslint-disable-next-line react/display-name
return (props) => {
const IconWrapper = (props) => (
// eslint-disable-next-line
<IconLoader {...props} color={theme.palette.warning.dark} className={`spin-animation ${props.className || ''}`} />
)
IconWrapper.displayName = 'InProgressIcon'
return <IconWrapper {...props} />
}
}
}
const getIconColor = (state) => {
switch (state) {
case 'FINISHED':
return 'success.dark'
case 'ERROR':
case 'TIMEOUT':
return 'error.main'
case 'TERMINATED':
case 'STOPPED':
return 'error.main'
case 'INPROGRESS':
return 'warning.main'
}
}
export const ExecutionsListTable = ({ data, isLoading, onExecutionRowClick, onSelectionChange }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const localStorageKeyOrder = 'executions_order'
const localStorageKeyOrderBy = 'executions_orderBy'
const [order, setOrder] = useState(localStorage.getItem(localStorageKeyOrder) || 'desc')
const [orderBy, setOrderBy] = useState(localStorage.getItem(localStorageKeyOrderBy) || 'updatedDate')
const [selected, setSelected] = useState([])
const handleRequestSort = (property) => {
const isAsc = orderBy === property && order === 'asc'
const newOrder = isAsc ? 'desc' : 'asc'
setOrder(newOrder)
setOrderBy(property)
localStorage.setItem(localStorageKeyOrder, newOrder)
localStorage.setItem(localStorageKeyOrderBy, property)
}
const handleSelectAllClick = (event) => {
if (event.target.checked) {
const newSelected = data.map((n) => n.id)
setSelected(newSelected)
onSelectionChange && onSelectionChange(newSelected)
} else {
setSelected([])
onSelectionChange && onSelectionChange([])
}
}
const handleClick = (event, id) => {
event.stopPropagation()
const selectedIndex = selected.indexOf(id)
let newSelected = []
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, id)
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1))
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1))
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1))
}
setSelected(newSelected)
onSelectionChange && onSelectionChange(newSelected)
}
const isSelected = (id) => selected.indexOf(id) !== -1
const sortedData = data
? [...data].sort((a, b) => {
if (orderBy === 'name') {
return order === 'asc' ? (a.name || '').localeCompare(b.name || '') : (b.name || '').localeCompare(a.name || '')
} else if (orderBy === 'updatedDate') {
return order === 'asc'
? new Date(a.updatedDate) - new Date(b.updatedDate)
: new Date(b.updatedDate) - new Date(a.updatedDate)
}
return 0
})
: []
return (
<>
<TableContainer sx={{ border: 1, borderColor: theme.palette.grey[900] + 25, borderRadius: 2 }} component={Paper}>
<Table sx={{ minWidth: 650 }} size='small' aria-label='a dense table'>
<TableHead
sx={{
backgroundColor: customization.isDarkMode ? theme.palette.common.black : theme.palette.grey[100],
height: 56
}}
>
<TableRow>
<StyledTableCell padding='checkbox'>
<Checkbox
color='primary'
indeterminate={selected.length > 0 && selected.length < data.length}
checked={data.length > 0 && selected.length === data.length}
onChange={handleSelectAllClick}
inputProps={{
'aria-label': 'select all executions'
}}
/>
</StyledTableCell>
<StyledTableCell>Status</StyledTableCell>
<StyledTableCell>
<TableSortLabel
active={orderBy === 'updatedDate'}
direction={order}
onClick={() => handleRequestSort('updatedDate')}
>
Last Updated
</TableSortLabel>
</StyledTableCell>
<StyledTableCell component='th' scope='row'>
<TableSortLabel active={orderBy === 'name'} direction={order} onClick={() => handleRequestSort('name')}>
Agentflow
</TableSortLabel>
</StyledTableCell>
<StyledTableCell>Session</StyledTableCell>
<StyledTableCell>
<TableSortLabel
active={orderBy === 'createdDate'}
direction={order}
onClick={() => handleRequestSort('createdDate')}
>
Created
</TableSortLabel>
</StyledTableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<>
<StyledTableRow>
<StyledTableCell padding='checkbox'>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
</StyledTableRow>
<StyledTableRow>
<StyledTableCell padding='checkbox'>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
<StyledTableCell>
<Skeleton variant='text' />
</StyledTableCell>
</StyledTableRow>
</>
) : (
<>
{sortedData.map((row, index) => {
const isItemSelected = isSelected(row.id)
const labelId = `enhanced-table-checkbox-${index}`
return (
<StyledTableRow
hover
key={index}
sx={{ cursor: 'pointer', '&:last-child td, &:last-child th': { border: 0 } }}
>
<StyledTableCell padding='checkbox'>
<Checkbox
color='primary'
checked={isItemSelected}
onClick={(event) => handleClick(event, row.id)}
inputProps={{
'aria-labelledby': labelId
}}
/>
</StyledTableCell>
<StyledTableCell onClick={() => onExecutionRowClick(row)}>
<Box
component={getIconFromStatus(row.state, theme)}
className='labelIcon'
color={getIconColor(row.state)}
/>
</StyledTableCell>
<StyledTableCell onClick={() => onExecutionRowClick(row)}>
{moment(row.updatedDate).format('MMM D, YYYY h:mm A')}
</StyledTableCell>
<StyledTableCell onClick={() => onExecutionRowClick(row)}>
{row.agentflow?.name}
</StyledTableCell>
<StyledTableCell onClick={() => onExecutionRowClick(row)}>{row.sessionId}</StyledTableCell>
<StyledTableCell onClick={() => onExecutionRowClick(row)}>
{moment(row.createdDate).format('MMM D, YYYY h:mm A')}
</StyledTableCell>
</StyledTableRow>
)
})}
</>
)}
</TableBody>
</Table>
</TableContainer>
</>
)
}
ExecutionsListTable.propTypes = {
data: PropTypes.array,
isLoading: PropTypes.bool,
onExecutionRowClick: PropTypes.func,
onSelectionChange: PropTypes.func,
className: PropTypes.string
}
ExecutionsListTable.displayName = 'ExecutionsListTable'
@@ -47,7 +47,7 @@ const getLocalStorageKeyName = (name, isAgentCanvas) => {
return (isAgentCanvas ? 'agentcanvas' : 'chatflowcanvas') + '_' + name
}
export const FlowListTable = ({ data, images, isLoading, filterFunction, updateFlowsApi, setError, isAgentCanvas }) => {
export const FlowListTable = ({ data, images = {}, icons = {}, isLoading, filterFunction, updateFlowsApi, setError, isAgentCanvas }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
@@ -66,6 +66,14 @@ export const FlowListTable = ({ data, images, isLoading, filterFunction, updateF
localStorage.setItem(localStorageKeyOrderBy, property)
}
const onFlowClick = (row) => {
if (!isAgentCanvas) {
return `/canvas/${row.id}`
} else {
return localStorage.getItem('agentFlowVersion') === 'v2' ? `/v2/agentcanvas/${row.id}` : `/agentcanvas/${row.id}`
}
}
const sortedData = data
? [...data].sort((a, b) => {
if (orderBy === 'name') {
@@ -170,10 +178,7 @@ export const FlowListTable = ({ data, images, isLoading, filterFunction, updateF
overflow: 'hidden'
}}
>
<Link
to={`/${isAgentCanvas ? 'agentcanvas' : 'canvas'}/${row.id}`}
style={{ color: '#2196f3', textDecoration: 'none' }}
>
<Link to={onFlowClick(row)} style={{ color: '#2196f3', textDecoration: 'none' }}>
{row.templateName || row.name}
</Link>
</Typography>
@@ -198,7 +203,7 @@ export const FlowListTable = ({ data, images, isLoading, filterFunction, updateF
</div>
</StyledTableCell>
<StyledTableCell key='2'>
{images[row.id] && (
{(images[row.id] || icons[row.id]) && (
<Box
sx={{
display: 'flex',
@@ -207,33 +212,55 @@ export const FlowListTable = ({ data, images, isLoading, filterFunction, updateF
gap: 1
}}
>
{images[row.id]
.slice(0, images[row.id].length > 5 ? 5 : images[row.id].length)
.map((img) => (
<Box
key={img}
sx={{
width: 30,
height: 30,
borderRadius: '50%',
backgroundColor: customization.isDarkMode
? theme.palette.common.white
: theme.palette.grey[300] + 75
}}
>
<img
style={{
width: '100%',
height: '100%',
padding: 5,
objectFit: 'contain'
{[
...(images[row.id] || []).map((img) => ({ type: 'image', src: img })),
...(icons[row.id] || []).map((ic) => ({
type: 'icon',
icon: ic.icon,
color: ic.color
}))
]
.slice(0, 5)
.map((item, index) =>
item.type === 'image' ? (
<Box
key={item.src}
sx={{
width: 30,
height: 30,
borderRadius: '50%',
backgroundColor: customization.isDarkMode
? theme.palette.common.white
: theme.palette.grey[300] + 75
}}
alt=''
src={img}
/>
</Box>
))}
{images[row.id].length > 5 && (
>
<img
style={{
width: '100%',
height: '100%',
padding: 5,
objectFit: 'contain'
}}
alt=''
src={item.src}
/>
</Box>
) : (
<div
key={index}
style={{
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<item.icon size={25} color={item.color} />
</div>
)
)}
{(images[row.id]?.length || 0) + (icons[row.id]?.length || 0) > 5 && (
<Typography
sx={{
alignItems: 'center',
@@ -242,7 +269,7 @@ export const FlowListTable = ({ data, images, isLoading, filterFunction, updateF
fontWeight: 200
}}
>
+ {images[row.id].length - 5} More
+ {(images[row.id]?.length || 0) + (icons[row.id]?.length || 0) - 5} More
</Typography>
)}
</Box>
@@ -280,6 +307,7 @@ export const FlowListTable = ({ data, images, isLoading, filterFunction, updateF
FlowListTable.propTypes = {
data: PropTypes.array,
images: PropTypes.object,
icons: PropTypes.object,
isLoading: PropTypes.bool,
filterFunction: PropTypes.func,
updateFlowsApi: PropTypes.object,
+41 -15
View File
@@ -1,8 +1,46 @@
import PropTypes from 'prop-types'
import { TableContainer, Table, TableHead, TableCell, TableRow, TableBody, Paper, Chip } from '@mui/material'
import { TableContainer, Table, TableHead, TableCell, TableRow, TableBody, Paper, Chip, Stack, Typography } from '@mui/material'
import { TooltipWithParser } from '@/ui-component/tooltip/TooltipWithParser'
export const TableViewOnly = ({ columns, rows, sx }) => {
// Helper function to safely render cell content
const renderCellContent = (key, row) => {
if (row[key] === null || row[key] === undefined) {
return ''
} else if (key === 'enabled') {
return row[key] ? <Chip label='Enabled' color='primary' /> : <Chip label='Disabled' />
} else if (key === 'type' && row.schema) {
// If there's schema information, add a tooltip
const schemaContent =
'[<br>' +
row.schema
.map(
(item) =>
`&nbsp;&nbsp;${JSON.stringify(
{
[item.name]: item.type
},
null,
2
)}`
)
.join(',<br>') +
'<br>]'
return (
<Stack direction='row' alignItems='center' spacing={1}>
<Typography>{row[key]}</Typography>
<TooltipWithParser title={`<div>Schema:<br/>${schemaContent}</div>`} />
</Stack>
)
} else if (typeof row[key] === 'object') {
// For other objects (that are not handled by special cases above)
return JSON.stringify(row[key])
} else {
return row[key]
}
}
return (
<>
<TableContainer component={Paper}>
@@ -32,20 +70,8 @@ export const TableViewOnly = ({ columns, rows, sx }) => {
{rows.map((row, index) => (
<TableRow key={index} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
{Object.keys(row).map((key, index) => {
if (key !== 'id') {
return (
<TableCell key={index}>
{key === 'enabled' ? (
row[key] ? (
<Chip label='Enabled' color='primary' />
) : (
<Chip label='Disabled' />
)
) : (
row[key]
)}
</TableCell>
)
if (key !== 'id' && key !== 'schema') {
return <TableCell key={index}>{renderCellContent(key, row)}</TableCell>
}
})}
</TableRow>
+2
View File
@@ -78,6 +78,7 @@ export const exportData = (exportAllData) => {
try {
return {
AgentFlow: sanitizeChatflow(exportAllData.AgentFlow),
AgentFlowV2: sanitizeChatflow(exportAllData.AgentFlowV2),
AssistantFlow: sanitizeChatflow(exportAllData.AssistantFlow),
AssistantCustom: sanitizeAssistant(exportAllData.AssistantCustom),
AssistantOpenAI: sanitizeAssistant(exportAllData.AssistantOpenAI),
@@ -88,6 +89,7 @@ export const exportData = (exportAllData) => {
CustomTemplate: exportAllData.CustomTemplate,
DocumentStore: exportAllData.DocumentStore,
DocumentStoreFileChunk: exportAllData.DocumentStoreFileChunk,
Execution: exportAllData.Execution,
Tool: sanitizeTool(exportAllData.Tool),
Variable: sanitizeVariable(exportAllData.Variable)
}
+308 -65
View File
@@ -1,4 +1,4 @@
import { uniq } from 'lodash'
import { uniq, get, isEqual } from 'lodash'
import moment from 'moment'
export const getUniqueNodeId = (nodeData, nodes) => {
@@ -16,6 +16,94 @@ export const getUniqueNodeId = (nodeData, nodes) => {
return baseId
}
export const getUniqueNodeLabel = (nodeData, nodes) => {
if (nodeData.type === 'StickyNote') return nodeData.label
if (nodeData.name === 'startAgentflow') return nodeData.label
let suffix = 0
// Construct base ID
let baseId = `${nodeData.name}_${suffix}`
// Increment suffix until a unique ID is found
while (nodes.some((node) => node.id === baseId)) {
suffix += 1
baseId = `${nodeData.name}_${suffix}`
}
return `${nodeData.label} ${suffix}`
}
const createAgentFlowOutputs = (nodeData, newNodeId) => {
if (nodeData.hideOutput) return []
if (nodeData.outputs?.length) {
return nodeData.outputs.map((_, index) => ({
id: `${newNodeId}-output-${index}`,
label: nodeData.label,
name: nodeData.name
}))
}
return [
{
id: `${newNodeId}-output-${nodeData.name}`,
label: nodeData.label,
name: nodeData.name
}
]
}
const createOutputOption = (output, newNodeId) => {
const outputBaseClasses = output.baseClasses ?? []
const baseClasses = outputBaseClasses.length > 1 ? outputBaseClasses.join('|') : outputBaseClasses[0] || ''
const type = outputBaseClasses.length > 1 ? outputBaseClasses.join(' | ') : outputBaseClasses[0] || ''
return {
id: `${newNodeId}-output-${output.name}-${baseClasses}`,
name: output.name,
label: output.label,
description: output.description ?? '',
type,
isAnchor: output?.isAnchor,
hidden: output?.hidden
}
}
const createStandardOutputs = (nodeData, newNodeId) => {
if (nodeData.hideOutput) return []
if (nodeData.outputs?.length) {
const outputOptions = nodeData.outputs.map((output) => createOutputOption(output, newNodeId))
return [
{
name: 'output',
label: 'Output',
type: 'options',
description: nodeData.outputs[0].description ?? '',
options: outputOptions,
default: nodeData.outputs[0].name
}
]
}
return [
{
id: `${newNodeId}-output-${nodeData.name}-${nodeData.baseClasses.join('|')}`,
name: nodeData.name,
label: nodeData.type,
description: nodeData.description ?? '',
type: nodeData.baseClasses.join(' | ')
}
]
}
const initializeOutputAnchors = (nodeData, newNodeId, isAgentflow) => {
return isAgentflow ? createAgentFlowOutputs(nodeData, newNodeId) : createStandardOutputs(nodeData, newNodeId)
}
export const initializeDefaultNodeData = (nodeParams) => {
const initialValues = {}
@@ -27,17 +115,17 @@ export const initializeDefaultNodeData = (nodeParams) => {
return initialValues
}
export const initNode = (nodeData, newNodeId) => {
export const initNode = (nodeData, newNodeId, isAgentflow) => {
const inputAnchors = []
const inputParams = []
const incoming = nodeData.inputs ? nodeData.inputs.length : 0
const outgoing = 1
const whitelistTypes = [
'asyncOptions',
'asyncMultiOptions',
'options',
'multiOptions',
'array',
'datagrid',
'string',
'number',
@@ -75,55 +163,7 @@ export const initNode = (nodeData, newNodeId) => {
}
// Outputs
const outputAnchors = []
for (let i = 0; i < outgoing; i += 1) {
if (nodeData.hideOutput) continue
if (nodeData.outputs && nodeData.outputs.length) {
const options = []
for (let j = 0; j < nodeData.outputs.length; j += 1) {
let baseClasses = ''
let type = ''
const outputBaseClasses = nodeData.outputs[j].baseClasses ?? []
if (outputBaseClasses.length > 1) {
baseClasses = outputBaseClasses.join('|')
type = outputBaseClasses.join(' | ')
} else if (outputBaseClasses.length === 1) {
baseClasses = outputBaseClasses[0]
type = outputBaseClasses[0]
}
const newOutputOption = {
id: `${newNodeId}-output-${nodeData.outputs[j].name}-${baseClasses}`,
name: nodeData.outputs[j].name,
label: nodeData.outputs[j].label,
description: nodeData.outputs[j].description ?? '',
type,
isAnchor: nodeData.outputs[j]?.isAnchor,
hidden: nodeData.outputs[j]?.hidden
}
options.push(newOutputOption)
}
const newOutput = {
name: 'output',
label: 'Output',
type: 'options',
description: nodeData.outputs[0].description ?? '',
options,
default: nodeData.outputs[0].name
}
outputAnchors.push(newOutput)
} else {
const newOutput = {
id: `${newNodeId}-output-${nodeData.name}-${nodeData.baseClasses.join('|')}`,
name: nodeData.name,
label: nodeData.type,
description: nodeData.description ?? '',
type: nodeData.baseClasses.join(' | ')
}
outputAnchors.push(newOutput)
}
}
let outputAnchors = initializeOutputAnchors(nodeData, newNodeId, isAgentflow)
/* Initial
inputs = [
@@ -160,9 +200,10 @@ export const initNode = (nodeData, newNodeId) => {
// Inputs
if (nodeData.inputs) {
nodeData.inputAnchors = inputAnchors
nodeData.inputParams = inputParams
nodeData.inputs = initializeDefaultNodeData(nodeData.inputs)
const defaultInputs = initializeDefaultNodeData(nodeData.inputs)
nodeData.inputAnchors = showHideInputAnchors({ ...nodeData, inputAnchors, inputs: defaultInputs })
nodeData.inputParams = showHideInputParams({ ...nodeData, inputParams, inputs: defaultInputs })
nodeData.inputs = defaultInputs
} else {
nodeData.inputAnchors = []
nodeData.inputParams = []
@@ -185,8 +226,10 @@ export const initNode = (nodeData, newNodeId) => {
return nodeData
}
export const updateOutdatedNodeData = (newComponentNodeData, existingComponentNodeData) => {
const initNewComponentNodeData = initNode(newComponentNodeData, existingComponentNodeData.id)
export const updateOutdatedNodeData = (newComponentNodeData, existingComponentNodeData, isAgentflow) => {
const initNewComponentNodeData = initNode(newComponentNodeData, existingComponentNodeData.id, isAgentflow)
const isAgentFlowV2 = newComponentNodeData.category === 'Agent Flows' || existingComponentNodeData.category === 'Agent Flows'
// Update credentials with existing credentials
if (existingComponentNodeData.credential) {
@@ -220,6 +263,11 @@ export const updateOutdatedNodeData = (newComponentNodeData, existingComponentNo
}
}
if (isAgentFlowV2) {
// persists the label from the existing node
initNewComponentNodeData.label = existingComponentNodeData.label
}
// Special case for Condition node to update outputAnchors
if (initNewComponentNodeData.name.includes('seqCondition')) {
const options = existingComponentNodeData.outputAnchors[0].options || []
@@ -243,22 +291,34 @@ export const updateOutdatedNodeData = (newComponentNodeData, existingComponentNo
export const updateOutdatedNodeEdge = (newComponentNodeData, edges) => {
const removedEdges = []
const isAgentFlowV2 = newComponentNodeData.category === 'Agent Flows'
for (const edge of edges) {
const targetNodeId = edge.targetHandle.split('-')[0]
const sourceNodeId = edge.sourceHandle.split('-')[0]
if (targetNodeId === newComponentNodeData.id) {
// Check if targetHandle is in inputParams or inputAnchors
const inputParam = newComponentNodeData.inputParams.find((param) => param.id === edge.targetHandle)
const inputAnchor = newComponentNodeData.inputAnchors.find((param) => param.id === edge.targetHandle)
if (isAgentFlowV2) {
if (edge.targetHandle !== newComponentNodeData.id) {
removedEdges.push(edge)
}
} else {
// Check if targetHandle is in inputParams or inputAnchors
const inputParam = newComponentNodeData.inputParams.find((param) => param.id === edge.targetHandle)
const inputAnchor = newComponentNodeData.inputAnchors.find((param) => param.id === edge.targetHandle)
if (!inputParam && !inputAnchor) {
removedEdges.push(edge)
if (!inputParam && !inputAnchor) {
removedEdges.push(edge)
}
}
}
if (sourceNodeId === newComponentNodeData.id) {
if (newComponentNodeData.outputAnchors?.length) {
if (isAgentFlowV2) {
// AgentFlow v2 doesn't have specific output anchors, connections are directly from node
// No need to remove edges for AgentFlow v2 outputs
} else if (newComponentNodeData.outputAnchors?.length) {
for (const outputAnchor of newComponentNodeData.outputAnchors) {
const outputAnchorType = outputAnchor.type
if (outputAnchorType === 'options') {
@@ -315,6 +375,63 @@ export const isValidConnection = (connection, reactFlowInstance) => {
return false
}
export const isValidConnectionAgentflowV2 = (connection, reactFlowInstance) => {
const source = connection.source
const target = connection.target
// Prevent self connections
if (source === target) {
return false
}
// Check if this connection would create a cycle in the graph
if (wouldCreateCycle(source, target, reactFlowInstance)) {
return false
}
return true
}
// Function to check if a new connection would create a cycle
const wouldCreateCycle = (sourceId, targetId, reactFlowInstance) => {
// The most direct cycle check: if target connects back to source
if (sourceId === targetId) {
return true
}
// Build directed graph from existing edges
const graph = {}
const edges = reactFlowInstance.getEdges()
// Initialize graph
edges.forEach((edge) => {
if (!graph[edge.source]) graph[edge.source] = []
graph[edge.source].push(edge.target)
})
// Check if there's a path from target to source (which would create a cycle when we add source → target)
const visited = new Set()
function hasPath(current, destination) {
if (current === destination) return true
if (visited.has(current)) return false
visited.add(current)
const neighbors = graph[current] || []
for (const neighbor of neighbors) {
if (hasPath(neighbor, destination)) {
return true
}
}
return false
}
// If there's a path from target to source, adding an edge from source to target will create a cycle
return hasPath(targetId, sourceId)
}
export const convertDateStringToDateObject = (dateString) => {
if (dateString === undefined || !dateString) return undefined
@@ -367,6 +484,21 @@ export const getFolderName = (base64ArrayStr) => {
}
}
const _removeCredentialId = (obj) => {
if (!obj || typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map((item) => _removeCredentialId(item))
}
const newObj = {}
for (const [key, value] of Object.entries(obj)) {
if (key === 'FLOWISE_CREDENTIAL_ID') continue
newObj[key] = _removeCredentialId(value)
}
return newObj
}
export const generateExportFlowData = (flowData) => {
const nodes = flowData.nodes
const edges = flowData.edges
@@ -381,6 +513,9 @@ export const generateExportFlowData = (flowData) => {
version: node.data.version,
name: node.data.name,
type: node.data.type,
color: node.data.color,
hideOutput: node.data.hideOutput,
hideInput: node.data.hideInput,
baseClasses: node.data.baseClasses,
tags: node.data.tags,
category: node.data.category,
@@ -406,7 +541,7 @@ export const generateExportFlowData = (flowData) => {
newNodeData.inputs = nodeDataInputs
}
nodes[i].data = newNodeData
nodes[i].data = _removeCredentialId(newNodeData)
}
const exportJson = {
nodes,
@@ -415,11 +550,13 @@ export const generateExportFlowData = (flowData) => {
return exportJson
}
export const getAvailableNodesForVariable = (nodes, edges, target, targetHandle) => {
export const getAvailableNodesForVariable = (nodes, edges, target, targetHandle, includesStart = false) => {
// example edge id = "llmChain_0-llmChain_0-output-outputPrediction-string|json-llmChain_1-llmChain_1-input-promptValues-string"
// {source} -{sourceHandle} -{target} -{targetHandle}
const parentNodes = []
const isAgentFlowV2 = nodes.find((nd) => nd.id === target)?.data?.category === 'Agent Flows'
const isSeqAgent = nodes.find((nd) => nd.id === target)?.data?.category === 'Sequential Agents'
function collectParentNodes(targetNodeId, nodes, edges) {
@@ -442,10 +579,35 @@ export const getAvailableNodesForVariable = (nodes, edges, target, targetHandle)
}
})
}
function collectAgentFlowV2ParentNodes(targetNodeId, nodes, edges) {
const inputEdges = edges.filter((edg) => edg.target === targetNodeId && edg.targetHandle === targetNodeId)
// Traverse each edge found
inputEdges.forEach((edge) => {
const parentNode = nodes.find((nd) => nd.id === edge.source)
if (!parentNode) return
// Recursive call to explore further up the tree
collectAgentFlowV2ParentNodes(parentNode.id, nodes, edges)
// Check and add the parent node to the list if it does not include specific names
const excludeNodeNames = ['startAgentflow']
if (!excludeNodeNames.includes(parentNode.data.name) || includesStart) {
parentNodes.push(parentNode)
}
})
}
if (isSeqAgent) {
collectParentNodes(target, nodes, edges)
return uniq(parentNodes)
} else if (isAgentFlowV2) {
collectAgentFlowV2ParentNodes(target, nodes, edges)
const parentNodeId = nodes.find((nd) => nd.id === target)?.parentNode
if (parentNodeId) {
collectAgentFlowV2ParentNodes(parentNodeId, nodes, edges)
}
return uniq(parentNodes)
} else {
const inputEdges = edges.filter((edg) => edg.target === target && edg.targetHandle === targetHandle)
if (inputEdges && inputEdges.length) {
@@ -931,3 +1093,84 @@ export const getCustomConditionOutputs = (value, nodeId, existingEdges, isDataGr
return { outputAnchors, toBeRemovedEdgeIds }
}
const _showHideOperation = (nodeData, inputParam, displayType, index) => {
const displayOptions = inputParam[displayType]
/* For example:
show: {
enableMemory: true
}
*/
Object.keys(displayOptions).forEach((path) => {
const comparisonValue = displayOptions[path]
if (path.includes('$index')) {
path = path.replace('$index', index)
}
const groundValue = get(nodeData.inputs, path, '')
if (Array.isArray(comparisonValue)) {
if (displayType === 'show' && !comparisonValue.includes(groundValue)) {
inputParam.display = false
}
if (displayType === 'hide' && comparisonValue.includes(groundValue)) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'string') {
if (displayType === 'show' && !(comparisonValue === groundValue || new RegExp(comparisonValue).test(groundValue))) {
inputParam.display = false
}
if (displayType === 'hide' && (comparisonValue === groundValue || new RegExp(comparisonValue).test(groundValue))) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'boolean') {
if (displayType === 'show' && comparisonValue !== groundValue) {
inputParam.display = false
}
if (displayType === 'hide' && comparisonValue === groundValue) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'object') {
if (displayType === 'show' && !isEqual(comparisonValue, groundValue)) {
inputParam.display = false
}
if (displayType === 'hide' && isEqual(comparisonValue, groundValue)) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'number') {
if (displayType === 'show' && comparisonValue !== groundValue) {
inputParam.display = false
}
if (displayType === 'hide' && comparisonValue === groundValue) {
inputParam.display = false
}
}
})
}
export const showHideInputs = (nodeData, inputType, overrideParams, arrayIndex) => {
const params = overrideParams ?? nodeData[inputType] ?? []
for (let i = 0; i < params.length; i += 1) {
const inputParam = params[i]
// Reset display flag to false for each inputParam
inputParam.display = true
if (inputParam.show) {
_showHideOperation(nodeData, inputParam, 'show', arrayIndex)
}
if (inputParam.hide) {
_showHideOperation(nodeData, inputParam, 'hide', arrayIndex)
}
}
return params
}
export const showHideInputParams = (nodeData) => {
return showHideInputs(nodeData, 'inputParams')
}
export const showHideInputAnchors = (nodeData) => {
return showHideInputs(nodeData, 'inputAnchors')
}
@@ -0,0 +1,983 @@
import { useEffect, useState, useCallback, forwardRef } from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'
import { useSelector, useDispatch } from 'react-redux'
// MUI
import { RichTreeView } from '@mui/x-tree-view/RichTreeView'
import { Typography, Box, Drawer, Chip, Button, Tooltip } from '@mui/material'
import { styled, alpha } from '@mui/material/styles'
import { useTreeItem2 } from '@mui/x-tree-view/useTreeItem2'
import {
TreeItem2Content,
TreeItem2IconContainer,
TreeItem2GroupTransition,
TreeItem2Label,
TreeItem2Root,
TreeItem2Checkbox
} from '@mui/x-tree-view/TreeItem2'
import { TreeItem2Icon } from '@mui/x-tree-view/TreeItem2Icon'
import { TreeItem2Provider } from '@mui/x-tree-view/TreeItem2Provider'
import { TreeItem2DragAndDropOverlay } from '@mui/x-tree-view/TreeItem2DragAndDropOverlay'
import DragHandleIcon from '@mui/icons-material/DragHandle'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import ErrorIcon from '@mui/icons-material/Error'
import { IconButton } from '@mui/material'
import {
IconRefresh,
IconExternalLink,
IconCopy,
IconLoader,
IconCircleXFilled,
IconRelationOneToManyFilled,
IconShare,
IconWorld,
IconX
} from '@tabler/icons-react'
// Project imports
import { useTheme } from '@mui/material/styles'
import { FLOWISE_CREDENTIAL_ID, AGENTFLOW_ICONS } from '@/store/constant'
import { NodeExecutionDetails } from '@/views/agentexecutions/NodeExecutionDetails'
import ShareExecutionDialog from './ShareExecutionDialog'
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions'
// API
import executionsApi from '@/api/executions'
// Hooks
import useApi from '@/hooks/useApi'
const getIconColor = (status) => {
switch (status) {
case 'FINISHED':
return 'success.dark'
case 'ERROR':
case 'TIMEOUT':
return 'error.main'
case 'TERMINATED':
case 'STOPPED':
return 'error.main'
case 'INPROGRESS':
return 'warning.dark'
}
}
const StyledTreeItemRoot = styled(TreeItem2Root)(({ theme }) => ({
color: theme.palette.grey[400]
}))
const CustomTreeItemContent = styled(TreeItem2Content)(({ theme }) => ({
flexDirection: 'row-reverse',
borderRadius: theme.spacing(0.7),
marginBottom: theme.spacing(0.5),
marginTop: theme.spacing(0.5),
padding: theme.spacing(0.5),
paddingRight: theme.spacing(1),
fontWeight: 500,
[`&.Mui-expanded `]: {
'&:not(.Mui-focused, .Mui-selected, .Mui-selected.Mui-focused) .labelIcon': {
color: theme.palette.primary.dark,
...theme.applyStyles('light', {
color: theme.palette.primary.main
})
},
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
left: '16px',
top: '44px',
height: 'calc(100% - 48px)',
width: '1.5px',
backgroundColor: theme.palette.grey[700],
...theme.applyStyles('light', {
backgroundColor: theme.palette.grey[300]
})
}
},
'&:hover': {
backgroundColor: alpha(theme.palette.primary.main, 0.1),
color: 'white',
...theme.applyStyles('light', {
color: theme.palette.primary.main
})
},
[`&.Mui-focused, &.Mui-selected, &.Mui-selected.Mui-focused`]: {
backgroundColor: theme.palette.primary.dark,
color: theme.palette.primary.contrastText,
...theme.applyStyles('light', {
backgroundColor: theme.palette.primary.main
})
}
}))
const StyledTreeItemLabelText = styled(Typography)(({ theme }) => ({
color: theme.palette.text.primary
}))
function CustomLabel({ icon: Icon, itemStatus, children, name, ...other }) {
// Check if this is an iteration node
const isIterationNode = name === 'iterationAgentflow'
return (
<TreeItem2Label
{...other}
sx={{
display: 'flex',
alignItems: 'center'
}}
>
{(() => {
// Display iteration icon for iteration nodes
if (isIterationNode) {
return (
<Box
sx={{
mr: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<IconRelationOneToManyFilled size={20} color={'#9C89B8'} />
</Box>
)
}
// Otherwise display the node icon
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === name)
if (foundIcon) {
return (
<Box
sx={{
mr: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<foundIcon.icon size={20} color={foundIcon.color} />
</Box>
)
}
return null
})()}
<StyledTreeItemLabelText sx={{ flex: 1 }}>{children}</StyledTreeItemLabelText>
{Icon && <Box component={Icon} className='labelIcon' color={getIconColor(itemStatus)} sx={{ ml: 1, fontSize: '1.2rem' }} />}
</TreeItem2Label>
)
}
CustomLabel.propTypes = {
icon: PropTypes.func,
itemStatus: PropTypes.string,
children: PropTypes.node,
name: PropTypes.string
}
CustomLabel.displayName = 'CustomLabel'
const isExpandable = (reactChildren) => {
if (Array.isArray(reactChildren)) {
return reactChildren.length > 0 && reactChildren.some(isExpandable)
}
return Boolean(reactChildren)
}
const getIconFromStatus = (status, theme) => {
switch (status) {
case 'FINISHED':
return CheckCircleIcon
case 'ERROR':
case 'TIMEOUT':
return ErrorIcon
case 'TERMINATED':
// eslint-disable-next-line react/display-name
return (props) => {
const IconWrapper = (props) => <IconCircleXFilled {...props} color={theme.palette.error.main} />
IconWrapper.displayName = 'TerminatedIcon'
return <IconWrapper {...props} />
}
case 'STOPPED':
return StopCircleIcon
case 'INPROGRESS':
// eslint-disable-next-line react/display-name
return (props) => {
const IconWrapper = (props) => (
// eslint-disable-next-line
<IconLoader {...props} color={theme.palette.warning.dark} className={`spin-animation ${props.className || ''}`} />
)
IconWrapper.displayName = 'InProgressIcon'
return <IconWrapper {...props} />
}
}
}
const CustomTreeItem = forwardRef(function CustomTreeItem(props, ref) {
const { id, itemId, label, disabled, children, ...other } = props
const theme = useTheme()
const {
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
publicAPI
} = useTreeItem2({ id, itemId, children, label, disabled, rootRef: ref })
const item = publicAPI.getItem(itemId)
const expandable = isExpandable(children)
let icon
if (item.status) {
icon = getIconFromStatus(item.status, theme)
}
return (
<TreeItem2Provider itemId={itemId}>
<StyledTreeItemRoot {...getRootProps(other)}>
<CustomTreeItemContent {...getContentProps()}>
<TreeItem2IconContainer {...getIconContainerProps()}>
<TreeItem2Icon status={status} />
</TreeItem2IconContainer>
<TreeItem2Checkbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
icon,
itemStatus: item.status,
expandable: expandable && status.expanded,
name: item.name || item.id?.split('_')[0]
})}
/>
<TreeItem2DragAndDropOverlay {...getDragAndDropOverlayProps()} />
</CustomTreeItemContent>
{children && (
<TreeItem2GroupTransition
{...getGroupTransitionProps()}
style={{
borderLeft: `${status.selected ? '3px solid' : '1px dashed'} ${(() => {
const nodeName = item.name || item.id?.split('_')[0]
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === nodeName)
return foundIcon ? foundIcon.color : theme.palette.primary.main
})()}`,
marginLeft: '13px',
paddingLeft: '8px'
}}
/>
)}
</StyledTreeItemRoot>
</TreeItem2Provider>
)
})
CustomTreeItem.propTypes = {
id: PropTypes.string,
itemId: PropTypes.string,
label: PropTypes.string,
disabled: PropTypes.bool,
children: PropTypes.node,
className: PropTypes.string
}
const MIN_DRAWER_WIDTH = 400
const DEFAULT_DRAWER_WIDTH = window.innerWidth - 400
const MAX_DRAWER_WIDTH = window.innerWidth
export const ExecutionDetails = ({ open, isPublic, execution, metadata, onClose, onProceedSuccess, onUpdateSharing, onRefresh }) => {
const [drawerWidth, setDrawerWidth] = useState(Math.min(DEFAULT_DRAWER_WIDTH, MAX_DRAWER_WIDTH))
const [executionTree, setExecution] = useState([])
const [expandedItems, setExpandedItems] = useState([])
const [selectedItem, setSelectedItem] = useState(null)
const [showShareDialog, setShowShareDialog] = useState(false)
const [copied, setCopied] = useState(false)
const [localMetadata, setLocalMetadata] = useState({})
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const updateExecutionApi = useApi(executionsApi.updateExecution)
const dispatch = useDispatch()
// useEffect to initialize localMetadata when metadata changes
useEffect(() => {
if (metadata) {
setLocalMetadata(metadata)
}
}, [metadata])
const copyToClipboard = () => {
navigator.clipboard.writeText(localMetadata?.id)
setCopied(true)
// Show success message
dispatch(
enqueueSnackbarAction({
message: 'ID copied to clipboard',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => dispatch(closeSnackbarAction(key))}>
<IconX />
</Button>
)
}
})
)
// Reset copied state after 2 seconds
setTimeout(() => {
setCopied(false)
}, 2000)
}
const handleMouseDown = () => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
const handleMouseMove = useCallback((e) => {
const newWidth = document.body.offsetWidth - e.clientX
if (newWidth >= MIN_DRAWER_WIDTH && newWidth <= MAX_DRAWER_WIDTH) {
setDrawerWidth(newWidth)
}
}, [])
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const getAllNodeIds = (nodes) => {
let ids = []
nodes.forEach((node) => {
ids.push(node.id)
if (node.children && node.children.length > 0) {
ids = [...ids, ...getAllNodeIds(node.children)]
}
})
return ids
}
// Transform the execution data into a tree structure
const buildTreeData = (nodes) => {
// for each node, loop through each and every nested key of node.data, and remove the key if it is equal to FLOWISE_CREDENTIAL_ID
nodes.forEach((node) => {
const removeFlowiseCredentialId = (data) => {
for (const key in data) {
if (key === FLOWISE_CREDENTIAL_ID) {
delete data[key]
}
if (typeof data[key] === 'object') {
removeFlowiseCredentialId(data[key])
}
}
}
removeFlowiseCredentialId(node.data)
})
// Create a map for quick node lookup
// Use execution index to make each node instance unique
const nodeMap = new Map()
nodes.forEach((node, index) => {
const uniqueNodeId = `${node.nodeId}_${index}`
nodeMap.set(uniqueNodeId, { ...node, uniqueNodeId, children: [], executionIndex: index })
})
// Identify iteration nodes and their children
const iterationGroups = new Map() // parentId -> Map of iterationIndex -> nodes
// Group iteration child nodes by their parent and iteration index
nodes.forEach((node, index) => {
if (node.data?.parentNodeId && node.data?.iterationIndex !== undefined) {
const parentId = node.data.parentNodeId
const iterationIndex = node.data.iterationIndex
if (!iterationGroups.has(parentId)) {
iterationGroups.set(parentId, new Map())
}
const iterationMap = iterationGroups.get(parentId)
if (!iterationMap.has(iterationIndex)) {
iterationMap.set(iterationIndex, [])
}
iterationMap.get(iterationIndex).push(`${node.nodeId}_${index}`)
}
})
// Create virtual iteration container nodes
iterationGroups.forEach((iterationMap, parentId) => {
iterationMap.forEach((nodeIds, iterationIndex) => {
// Find the parent iteration node
let parentNode = null
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].nodeId === parentId) {
parentNode = nodes[i]
break
}
}
if (!parentNode) return
// Get iteration context from first child node
const firstChildId = nodeIds[0]
const firstChild = nodeMap.get(firstChildId)
const iterationContext = firstChild?.data?.iterationContext || { index: iterationIndex }
// Create a virtual node for this iteration
const iterationNodeId = `${parentId}_${iterationIndex}`
const iterationLabel = `Iteration #${iterationIndex}`
// Determine status based on child nodes
const childNodes = nodeIds.map((id) => nodeMap.get(id))
const iterationStatus = childNodes.some((n) => n.status === 'ERROR')
? 'ERROR'
: childNodes.some((n) => n.status === 'INPROGRESS')
? 'INPROGRESS'
: childNodes.every((n) => n.status === 'FINISHED')
? 'FINISHED'
: 'UNKNOWN'
// Create the virtual node and add to nodeMap
const virtualNode = {
nodeId: iterationNodeId,
nodeLabel: iterationLabel,
data: {
name: 'iterationAgentflow',
iterationIndex,
iterationContext,
isVirtualNode: true,
parentIterationId: parentId
},
previousNodeIds: [], // Will be handled in the main tree building
status: iterationStatus,
uniqueNodeId: iterationNodeId,
children: [],
executionIndex: -1 // Flag as a virtual node
}
nodeMap.set(iterationNodeId, virtualNode)
// Set this virtual node as the parent for all nodes in this iteration
nodeIds.forEach((childId) => {
const childNode = nodeMap.get(childId)
if (childNode) {
childNode.virtualParentId = iterationNodeId
}
})
})
})
// Root nodes have no previous nodes
const rootNodes = []
const processedNodes = new Set()
// First pass: Build the main tree structure (excluding iteration children)
nodes.forEach((node, index) => {
const uniqueNodeId = `${node.nodeId}_${index}`
const treeNode = nodeMap.get(uniqueNodeId)
// Skip nodes that belong to an iteration (they'll be added to their virtual parent)
if (node.data?.parentNodeId && node.data?.iterationIndex !== undefined) {
return
}
if (node.previousNodeIds.length === 0) {
rootNodes.push(treeNode)
} else {
// Find the most recent (latest) parent node among all previous nodes
let mostRecentParentIndex = -1
let mostRecentParentId = null
node.previousNodeIds.forEach((parentId) => {
// Find the most recent instance of this parent node
for (let i = 0; i < index; i++) {
if (nodes[i].nodeId === parentId && i > mostRecentParentIndex) {
mostRecentParentIndex = i
mostRecentParentId = parentId
}
}
})
// Only add to the most recent parent
if (mostRecentParentIndex !== -1) {
const parentUniqueId = `${mostRecentParentId}_${mostRecentParentIndex}`
const parentNode = nodeMap.get(parentUniqueId)
if (parentNode) {
parentNode.children.push(treeNode)
processedNodes.add(uniqueNodeId)
}
}
}
})
// Second pass: Build the iteration sub-trees
iterationGroups.forEach((iterationMap, parentId) => {
// Find all instances of the parent node
const parentInstances = []
nodes.forEach((node, index) => {
if (node.nodeId === parentId) {
parentInstances.push(`${node.nodeId}_${index}`)
}
})
// Find the latest instance of the parent node that exists in the tree
let latestParent = null
for (let i = parentInstances.length - 1; i >= 0; i--) {
const parentId = parentInstances[i]
const parent = nodeMap.get(parentId)
if (parent) {
latestParent = parent
break
}
}
if (!latestParent) return
// Add all virtual iteration nodes to the parent
iterationMap.forEach((nodeIds, iterationIndex) => {
const iterationNodeId = `${parentId}_${iterationIndex}`
const virtualNode = nodeMap.get(iterationNodeId)
if (virtualNode) {
latestParent.children.push(virtualNode)
}
})
})
// Third pass: Build the structure inside each virtual iteration node
nodeMap.forEach((node) => {
if (node.virtualParentId) {
const virtualParent = nodeMap.get(node.virtualParentId)
if (virtualParent) {
if (node.previousNodeIds.length === 0) {
// This is a root node within the iteration
virtualParent.children.push(node)
} else {
// Find its parent within the same iteration
let parentFound = false
for (const prevNodeId of node.previousNodeIds) {
// Look for nodes with the same previous node ID in the same iteration
nodeMap.forEach((potentialParent) => {
if (
potentialParent.nodeId === prevNodeId &&
potentialParent.data?.iterationIndex === node.data?.iterationIndex &&
potentialParent.data?.parentNodeId === node.data?.parentNodeId &&
!parentFound
) {
potentialParent.children.push(node)
parentFound = true
}
})
}
// If no parent was found within the iteration, add directly to virtual parent
if (!parentFound) {
virtualParent.children.push(node)
}
}
}
}
})
// Final pass: Sort all children arrays to ensure iteration nodes appear first
const sortChildrenNodes = (node) => {
if (node.children && node.children.length > 0) {
// Sort children: iteration nodes first, then others by their original execution order
node.children.sort((a, b) => {
// Check if a is an iteration node
const aIsIteration = a.data?.name === 'iterationAgentflow' || a.data?.isVirtualNode
// Check if b is an iteration node
const bIsIteration = b.data?.name === 'iterationAgentflow' || b.data?.isVirtualNode
// If both are iterations or both are not iterations, preserve original order
if (aIsIteration === bIsIteration) {
return a.executionIndex - b.executionIndex
}
// Otherwise, put iterations first
return aIsIteration ? -1 : 1
})
// Recursively sort children's children
node.children.forEach(sortChildrenNodes)
}
}
// Apply sorting to all root nodes and their children
rootNodes.forEach(sortChildrenNodes)
// Transform to the required format
const transformNode = (node) => ({
id: node.uniqueNodeId,
label: node.nodeLabel,
name: node.data?.name,
status: node.status,
data: node.data,
children: node.children.map(transformNode)
})
return rootNodes.map(transformNode)
}
const handleExpandedItemsChange = (event, itemIds) => {
setExpandedItems(itemIds)
}
const onSharePublicly = () => {
const newIsPublic = !localMetadata.isPublic
updateExecutionApi.request(localMetadata.id, { isPublic: newIsPublic }).then(() => {
// Update local metadata to reflect the change
setLocalMetadata((prev) => ({
...prev,
isPublic: newIsPublic
}))
// Show success message
dispatch(
enqueueSnackbarAction({
message: newIsPublic ? 'Execution shared publicly' : 'Execution is no longer public',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => dispatch(closeSnackbarAction(key))}>
<IconX />
</Button>
)
}
})
)
// Notify parent component to refresh data
if (onUpdateSharing) {
onUpdateSharing()
}
})
}
useEffect(() => {
if (execution) {
const newTree = buildTreeData(execution)
// Find first stopped item if metadata state is STOPPED
if (metadata?.state === 'STOPPED') {
const findFirstStoppedNode = (nodes) => {
for (const node of nodes) {
if (node.status === 'STOPPED') return node
if (node.children) {
const found = findFirstStoppedNode(node.children)
if (found) return found
}
}
return null
}
const stoppedNode = findFirstStoppedNode(newTree)
if (stoppedNode) {
setExpandedItems(getAllNodeIds(newTree))
setSelectedItem(stoppedNode)
} else {
setExpandedItems(getAllNodeIds(newTree))
// Set the first item as default selected item
if (newTree.length > 0) {
setSelectedItem(newTree[0])
}
}
} else {
setExpandedItems(getAllNodeIds(newTree))
// Set the first item as default selected item
if (newTree.length > 0) {
setSelectedItem(newTree[0])
}
}
setExecution(newTree)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [execution, metadata])
const handleNodeSelect = (event, itemId) => {
const findNode = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
const selectedNode = findNode(executionTree, itemId)
setSelectedItem(selectedNode)
}
// Content to be rendered in both drawer and full page modes
const contentComponent = (
<Box sx={{ display: 'flex', height: '100%', flexDirection: 'row' }}>
<Box
sx={{
flex: '1 1 35%',
padding: 2,
borderRight: 1,
borderColor: 'divider'
}}
>
<Box
sx={{
pb: 1,
mb: 2,
backgroundColor: (theme) => theme.palette.background.paper,
borderBottom: 1,
borderColor: 'divider'
}}
>
<Box>
{!isPublic && (
<Chip
sx={{ pl: 1 }}
icon={<IconExternalLink size={15} />}
variant='outlined'
label={metadata?.agentflow?.name || metadata?.agentflow?.id || 'Go to AgentFlow'}
className={'button'}
onClick={() => window.open(`/v2/agentcanvas/${metadata?.agentflow?.id}`, '_blank')}
/>
)}
{!isPublic && (
<Tooltip
title={`Execution ID: ${localMetadata?.id || ''}`}
placement='top'
disableHoverListener={!localMetadata?.id}
>
<Chip
sx={{ ml: 1, pl: 1 }}
icon={<IconCopy size={15} />}
variant='outlined'
label={copied ? 'Copied!' : 'Copy ID'}
className={'button'}
onClick={copyToClipboard}
/>
</Tooltip>
)}
{!isPublic && !localMetadata.isPublic && (
<Chip
sx={{ ml: 1, pl: 1 }}
icon={
updateExecutionApi.loading ? (
<IconLoader size={15} className='spin-animation' />
) : (
<IconShare size={15} />
)
}
variant='outlined'
label={updateExecutionApi.loading ? 'Updating...' : 'Share'}
className={'button'}
onClick={() => onSharePublicly()}
disabled={updateExecutionApi.loading}
/>
)}
{!isPublic && localMetadata.isPublic && (
<Chip
sx={{ ml: 1, pl: 1 }}
icon={
updateExecutionApi.loading ? (
<IconLoader size={15} className='spin-animation' />
) : (
<IconWorld size={15} />
)
}
variant='outlined'
label={updateExecutionApi.loading ? 'Updating...' : 'Public'}
className={'button'}
onClick={() => setShowShareDialog(true)}
disabled={updateExecutionApi.loading}
/>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', alignContent: 'center' }}>
<Typography sx={{ flex: 1, mt: 1 }} color='text.primary'>
{metadata?.updatedDate ? moment(metadata.updatedDate).format('MMM D, YYYY h:mm A') : 'N/A'}
</Typography>
<IconButton
onClick={() => onRefresh(localMetadata?.id)}
size='small'
sx={{
color: theme.palette.text.primary,
'&:hover': {
backgroundColor: (theme) => theme.palette.primary.main + '20'
}
}}
title='Refresh execution data'
>
<IconRefresh size={20} />
</IconButton>
</Box>
</Box>
</Box>
<RichTreeView
expandedItems={expandedItems}
onExpandedItemsChange={handleExpandedItemsChange}
selectedItems={selectedItem ? [selectedItem.id] : []}
onSelectedItemsChange={handleNodeSelect}
items={executionTree}
slots={{
item: CustomTreeItem
}}
/>
</Box>
<Box
sx={{
flex: '1 1 65%',
padding: 2,
overflow: 'auto'
}}
>
{selectedItem && selectedItem.data ? (
<NodeExecutionDetails
data={selectedItem.data}
label={selectedItem.label}
status={selectedItem.status}
metadata={metadata}
isPublic={isPublic}
onProceedSuccess={onProceedSuccess}
/>
) : (
<Typography color='text.secondary'>No data available for this item</Typography>
)}
</Box>
</Box>
)
// Resize handle component (shared between modes)
const resizeHandle = (
<button
aria-label='Resize drawer'
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '8px',
cursor: 'ew-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
border: 'none',
background: 'transparent',
'&:hover': {
background: 'rgba(0, 0, 0, 0.1)'
}
}}
onMouseDown={handleMouseDown}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
// Start resize mode
handleMouseDown()
}
}}
>
<DragHandleIcon
sx={{
transform: 'rotate(90deg)',
fontSize: '20px',
color: customization.isDarkMode ? 'white' : 'action.disabled'
}}
/>
</button>
)
// Render as full page component if isPublic is true
if (isPublic) {
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1300,
backgroundColor: (theme) => theme.palette.background.paper
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
position: 'relative'
}}
>
{contentComponent}
</Box>
</Box>
)
}
// Render as drawer component (original behavior)
return (
<>
<Drawer
variant='temporary'
anchor='right'
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: drawerWidth,
height: '100%'
}
}}
open={open}
onClose={onClose}
>
{resizeHandle}
{contentComponent}
</Drawer>
<ShareExecutionDialog
show={showShareDialog}
executionId={localMetadata?.id}
onClose={() => setShowShareDialog(false)}
onUnshare={() => {
updateExecutionApi.request(localMetadata.id, { isPublic: false }).then(() => {
// Update local metadata to reflect the change
setLocalMetadata((prev) => ({
...prev,
isPublic: false
}))
setShowShareDialog(false)
// Notify parent component to refresh data
if (onUpdateSharing) {
onUpdateSharing()
}
})
}}
/>
</>
)
}
ExecutionDetails.propTypes = {
open: PropTypes.bool,
isPublic: PropTypes.bool,
execution: PropTypes.array,
metadata: PropTypes.object,
onClose: PropTypes.func,
onProceedSuccess: PropTypes.func,
onUpdateSharing: PropTypes.func,
onRefresh: PropTypes.func
}
ExecutionDetails.displayName = 'ExecutionDetails'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { ExecutionDetails } from './ExecutionDetails'
import { omit } from 'lodash'
// API
import executionsApi from '@/api/executions'
// Hooks
import useApi from '@/hooks/useApi'
// MUI
import { Box, Card, Stack, Typography, useTheme } from '@mui/material'
import { IconCircleXFilled } from '@tabler/icons-react'
import { alpha } from '@mui/material/styles'
// ==============================|| PublicExecutionDetails ||============================== //
const PublicExecutionDetails = () => {
const { id: executionId } = useParams()
const theme = useTheme()
const [execution, setExecution] = useState(null)
const [selectedMetadata, setSelectedMetadata] = useState({})
const [isLoading, setLoading] = useState(true)
const getExecutionByIdPublicApi = useApi(executionsApi.getExecutionByIdPublic)
useEffect(() => {
getExecutionByIdPublicApi.request(executionId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (getExecutionByIdPublicApi.data) {
const execution = getExecutionByIdPublicApi.data
const executionDetails =
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
setExecution(executionDetails)
setSelectedMetadata(omit(execution, ['executionData']))
}
}, [getExecutionByIdPublicApi.data])
useEffect(() => {
setLoading(getExecutionByIdPublicApi.loading)
}, [getExecutionByIdPublicApi.loading])
return (
<>
{!isLoading ? (
<>
{!execution || getExecutionByIdPublicApi.error ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '80vh' }}>
<Box sx={{ maxWidth: '500px', width: '100%' }}>
<Card
variant='outlined'
sx={{
border: `1px solid ${theme.palette.error.main}`,
borderRadius: 2,
padding: '20px',
boxShadow: `0 4px 8px ${alpha(theme.palette.error.main, 0.15)}`
}}
>
<Stack spacing={2} alignItems='center'>
<IconCircleXFilled size={50} color={theme.palette.error.main} />
<Typography variant='h3' color='error.main' align='center'>
Invalid Execution
</Typography>
<Typography variant='body1' color='text.secondary' align='center'>
{`The execution you're looking for doesn't exist or you don't have permission to view it.`}
</Typography>
</Stack>
</Card>
</Box>
</Box>
) : (
<ExecutionDetails
isPublic={true}
execution={execution}
metadata={selectedMetadata}
onProceedSuccess={() => {
getExecutionByIdPublicApi.request(executionId)
}}
onRefresh={(executionId) => {
getExecutionByIdPublicApi.request(executionId)
}}
/>
)}
</>
) : null}
</>
)
}
export default PublicExecutionDetails
@@ -0,0 +1,126 @@
import { createPortal } from 'react-dom'
import PropTypes from 'prop-types'
import { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
// Material
import { Typography, Box, Dialog, DialogContent, DialogTitle, Button, Tooltip } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconCopy, IconX, IconLink } from '@tabler/icons-react'
// Constants
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions'
// API
import executionsApi from '@/api/executions'
import useApi from '@/hooks/useApi'
const ShareExecutionDialog = ({ show, executionId, onClose, onUnshare }) => {
const portalElement = document.getElementById('portal')
const theme = useTheme()
const dispatch = useDispatch()
const customization = useSelector((state) => state.customization)
const [copied, setCopied] = useState(false)
const updateExecutionApi = useApi(executionsApi.updateExecution)
// Create shareable link
const origin = window.location.origin
const shareableLink = `${origin}/execution/${executionId}`
const copyToClipboard = () => {
navigator.clipboard.writeText(shareableLink)
setCopied(true)
// Show success message
dispatch(
enqueueSnackbarAction({
message: 'Link copied to clipboard',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => dispatch(closeSnackbarAction(key))}>
<IconX />
</Button>
)
}
})
)
// Reset copied state after 2 seconds
setTimeout(() => {
setCopied(false)
}, 2000)
}
const handleUnshare = () => {
updateExecutionApi.request(executionId, { isPublic: false })
if (onUnshare) onUnshare()
onClose()
}
const component = show ? (
<Dialog open={show} onClose={onClose} maxWidth='sm' fullWidth aria-labelledby='share-dialog-title'>
<DialogTitle id='share-dialog-title' sx={{ fontSize: '1.1rem', fontWeight: 600 }}>
Public Trace Link
</DialogTitle>
<DialogContent>
<Typography variant='body2' color='text.secondary' sx={{ mb: 2 }}>
Anyone with the link below can view this execution trace.
</Typography>
{/* Link Display Box */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 3,
p: 1,
border: `1px solid ${theme.palette.divider}`,
borderRadius: '8px',
backgroundColor: customization.isDarkMode ? theme.palette.background.paper : theme.palette.grey[100]
}}
>
<IconLink size={20} style={{ marginRight: '8px', color: theme.palette.text.secondary }} />
<Typography
variant='body2'
sx={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
color: theme.palette.primary.main,
mr: 1
}}
>
{shareableLink}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy link'}>
<Button variant='text' color='primary' onClick={copyToClipboard} startIcon={<IconCopy size={18} />}>
Copy
</Button>
</Tooltip>
</Box>
{/* Actions */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button color='error' onClick={handleUnshare} sx={{ mr: 1 }}>
Unshare
</Button>
<Button onClick={onClose}>Close</Button>
</Box>
</DialogContent>
</Dialog>
) : null
return createPortal(component, portalElement)
}
ShareExecutionDialog.propTypes = {
show: PropTypes.bool,
executionId: PropTypes.string,
onClose: PropTypes.func,
onUnshare: PropTypes.func
}
export default ShareExecutionDialog
@@ -0,0 +1,464 @@
import { useEffect, useState } from 'react'
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
// material-ui
import {
Pagination,
Box,
Stack,
TextField,
MenuItem,
Button,
Grid,
FormControl,
InputLabel,
Select,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Tooltip,
Typography,
useTheme
} from '@mui/material'
// project imports
import MainCard from '@/ui-component/cards/MainCard'
import ErrorBoundary from '@/ErrorBoundary'
import ViewHeader from '@/layout/MainLayout/ViewHeader'
// API
import useApi from '@/hooks/useApi'
import executionsApi from '@/api/executions'
import { useSelector } from 'react-redux'
// icons
import execution_empty from '@/assets/images/executions_empty.svg'
import { IconTrash } from '@tabler/icons-react'
// const
import { ExecutionsListTable } from '@/ui-component/table/ExecutionsListTable'
import { ExecutionDetails } from './ExecutionDetails'
import { omit } from 'lodash'
// ==============================|| AGENT EXECUTIONS ||============================== //
const AgentExecutions = () => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const borderColor = theme.palette.grey[900] + 25
const getAllExecutions = useApi(executionsApi.getAllExecutions)
const deleteExecutionsApi = useApi(executionsApi.deleteExecutions)
const getExecutionByIdApi = useApi(executionsApi.getExecutionById)
const [error, setError] = useState(null)
const [isLoading, setLoading] = useState(true)
const [executions, setExecutions] = useState([])
const [openDrawer, setOpenDrawer] = useState(false)
const [selectedExecutionData, setSelectedExecutionData] = useState([])
const [selectedMetadata, setSelectedMetadata] = useState({})
const [selectedExecutionIds, setSelectedExecutionIds] = useState([])
const [openDeleteDialog, setOpenDeleteDialog] = useState(false)
const [filters, setFilters] = useState({
state: '',
startDate: null,
endDate: null,
agentflowId: '',
sessionId: ''
})
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0
})
const handleFilterChange = (field, value) => {
setFilters({
...filters,
[field]: value
})
}
const onDateChange = (field, date) => {
const updatedDate = new Date(date)
updatedDate.setHours(0, 0, 0, 0)
setFilters({
...filters,
[field]: updatedDate
})
}
const handlePageChange = (event, newPage) => {
setPagination({
...pagination,
page: newPage
})
}
const handleLimitChange = (event) => {
setPagination({
...pagination,
page: 1, // Reset to first page when changing items per page
limit: parseInt(event.target.value, 10)
})
}
const applyFilters = () => {
setLoading(true)
const params = {
page: pagination.page,
limit: pagination.limit
}
if (filters.state) params.state = filters.state
// Create date strings that preserve the exact date values
if (filters.startDate) {
const date = new Date(filters.startDate)
// Format date as YYYY-MM-DD and set to start of day in UTC
// This ensures the server sees the same date we've selected regardless of timezone
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
params.startDate = `${year}-${month}-${day}T00:00:00.000Z`
}
if (filters.endDate) {
const date = new Date(filters.endDate)
// Format date as YYYY-MM-DD and set to end of day in UTC
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
params.endDate = `${year}-${month}-${day}T23:59:59.999Z`
}
if (filters.agentflowId) params.agentflowId = filters.agentflowId
if (filters.sessionId) params.sessionId = filters.sessionId
getAllExecutions.request(params)
}
const resetFilters = () => {
setFilters({
state: '',
startDate: null,
endDate: null,
agentflowId: '',
sessionId: ''
})
getAllExecutions.request()
}
const handleExecutionSelectionChange = (selectedIds) => {
setSelectedExecutionIds(selectedIds)
}
const handleDeleteDialogOpen = () => {
if (selectedExecutionIds.length > 0) {
setOpenDeleteDialog(true)
}
}
const handleDeleteDialogClose = () => {
setOpenDeleteDialog(false)
}
const handleDeleteExecutions = () => {
deleteExecutionsApi.request(selectedExecutionIds)
setOpenDeleteDialog(false)
}
useEffect(() => {
getAllExecutions.request()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (getAllExecutions.data) {
try {
const { data, total } = getAllExecutions.data
if (!Array.isArray(data)) return
setExecutions(data)
setPagination((prev) => ({ ...prev, total }))
} catch (e) {
console.error(e)
}
}
}, [getAllExecutions.data])
useEffect(() => {
setLoading(getAllExecutions.loading)
}, [getAllExecutions.loading])
useEffect(() => {
setError(getAllExecutions.error)
}, [getAllExecutions.error])
useEffect(() => {
applyFilters()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.page, pagination.limit])
useEffect(() => {
if (deleteExecutionsApi.data) {
// Refresh the executions list
getAllExecutions.request({
page: pagination.page,
limit: pagination.limit
})
setSelectedExecutionIds([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deleteExecutionsApi.data])
useEffect(() => {
if (getExecutionByIdApi.data) {
const execution = getExecutionByIdApi.data
const executionDetails =
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
setSelectedExecutionData(executionDetails)
setSelectedMetadata(omit(execution, ['executionData']))
}
}, [getExecutionByIdApi.data])
return (
<MainCard>
{error ? (
<ErrorBoundary error={error} />
) : (
<Stack flexDirection='column' sx={{ gap: 3 }}>
<ViewHeader title='Agent Executions' description='Monitor and manage agentflows executions' />
{/* Filter Section */}
<Box sx={{ mb: 2, width: '100%' }}>
<Grid container spacing={2} alignItems='center'>
<Grid item xs={12} md={2}>
<FormControl fullWidth size='small'>
<InputLabel id='state-select-label'>State</InputLabel>
<Select
labelId='state-select-label'
value={filters.state}
label='State'
onChange={(e) => handleFilterChange('state', e.target.value)}
size='small'
sx={{
'& .MuiOutlinedInput-notchedOutline': {
borderColor: borderColor
},
'& .MuiSvgIcon-root': {
color: customization.isDarkMode ? '#fff' : 'inherit'
}
}}
>
<MenuItem value=''>All</MenuItem>
<MenuItem value='INPROGRESS'>In Progress</MenuItem>
<MenuItem value='FINISHED'>Finished</MenuItem>
<MenuItem value='ERROR'>Error</MenuItem>
<MenuItem value='TERMINATED'>Terminated</MenuItem>
<MenuItem value='TIMEOUT'>Timeout</MenuItem>
<MenuItem value='STOPPED'>Stopped</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={2}>
<DatePicker
selected={filters.startDate}
onChange={(date) => onDateChange('startDate', date)}
selectsStart
startDate={filters.startDate}
className='form-control'
wrapperClassName='datePicker'
maxDate={new Date()}
customInput={
<TextField
size='small'
label='Start date'
fullWidth
sx={{
'& .MuiOutlinedInput-notchedOutline': {
borderColor: borderColor
}
}}
/>
}
/>
</Grid>
<Grid sx={{ ml: -1 }} item xs={12} md={2}>
<DatePicker
selected={filters.endDate}
onChange={(date) => onDateChange('endDate', date)}
selectsEnd
endDate={filters.endDate}
className='form-control'
wrapperClassName='datePicker'
minDate={filters.startDate}
maxDate={new Date()}
customInput={
<TextField
size='small'
label='End date'
fullWidth
sx={{
'& .MuiOutlinedInput-notchedOutline': {
borderColor: borderColor
}
}}
/>
}
/>
</Grid>
<Grid sx={{ ml: -1 }} item xs={12} md={2}>
<TextField
fullWidth
label='Session ID'
value={filters.sessionId}
onChange={(e) => handleFilterChange('sessionId', e.target.value)}
size='small'
sx={{
'& .MuiOutlinedInput-notchedOutline': {
borderColor: borderColor
}
}}
/>
</Grid>
<Grid item xs={12} md={4}>
<Stack direction='row' spacing={1}>
<Button variant='contained' color='primary' onClick={applyFilters} size='small'>
Apply
</Button>
<Button variant='outlined' onClick={resetFilters} size='small'>
Reset
</Button>
<Tooltip title='Delete selected executions'>
<span>
<IconButton
sx={{ height: 30, width: 30 }}
size='small'
color='error'
onClick={handleDeleteDialogOpen}
edge='end'
disabled={selectedExecutionIds.length === 0}
>
<IconTrash />
</IconButton>
</span>
</Tooltip>
</Stack>
</Grid>
</Grid>
</Box>
<ExecutionsListTable
data={executions}
isLoading={isLoading}
onSelectionChange={handleExecutionSelectionChange}
onExecutionRowClick={(execution) => {
setOpenDrawer(true)
const executionDetails =
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
setSelectedExecutionData(executionDetails)
setSelectedMetadata(omit(execution, ['executionData']))
}}
/>
{/* Pagination and Page Size Controls */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant='body2'>Items per page:</Typography>
<FormControl
variant='outlined'
size='small'
sx={{
minWidth: 80,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: borderColor
},
'& .MuiSvgIcon-root': {
color: customization.isDarkMode ? '#fff' : 'inherit'
}
}}
>
<Select value={pagination.limit} onChange={handleLimitChange} displayEmpty>
<MenuItem value={10}>10</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value={100}>100</MenuItem>
<MenuItem value={1000}>1000</MenuItem>
</Select>
</FormControl>
</Box>
<Pagination
count={Math.ceil(pagination.total / pagination.limit)}
page={pagination.page}
onChange={handlePageChange}
color='primary'
/>
</Box>
<ExecutionDetails
open={openDrawer}
execution={selectedExecutionData}
metadata={selectedMetadata}
onClose={() => setOpenDrawer(false)}
onProceedSuccess={() => {
setOpenDrawer(false)
getAllExecutions.request()
}}
onUpdateSharing={() => {
getAllExecutions.request()
}}
onRefresh={(executionId) => {
getAllExecutions.request()
getExecutionByIdApi.request(executionId)
}}
/>
{/* Delete Confirmation Dialog */}
<Dialog
open={openDeleteDialog}
onClose={handleDeleteDialogClose}
aria-labelledby='alert-dialog-title'
aria-describedby='alert-dialog-description'
>
<DialogTitle id='alert-dialog-title'>Confirm Deletion</DialogTitle>
<DialogContent>
<DialogContentText id='alert-dialog-description'>
Are you sure you want to delete {selectedExecutionIds.length} execution
{selectedExecutionIds.length !== 1 ? 's' : ''}? This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteDialogClose} color='primary'>
Cancel
</Button>
<Button onClick={handleDeleteExecutions} color='error'>
Delete
</Button>
</DialogActions>
</Dialog>
{!isLoading && (!executions || executions.length === 0) && (
<Stack sx={{ alignItems: 'center', justifyContent: 'center' }} flexDirection='column'>
<Box sx={{ p: 2, height: 'auto' }}>
<img
style={{ objectFit: 'cover', height: '20vh', width: 'auto' }}
src={execution_empty}
alt='execution_empty'
/>
</Box>
<div>No Executions Yet</div>
</Stack>
)}
</Stack>
)}
</MainCard>
)
}
export default AgentExecutions
+81 -10
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
// material-ui
import { Box, Skeleton, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material'
import { Chip, Box, Skeleton, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material'
import { useTheme } from '@mui/material/styles'
// project imports
@@ -24,7 +24,7 @@ import chatflowsApi from '@/api/chatflows'
import useApi from '@/hooks/useApi'
// const
import { baseURL } from '@/store/constant'
import { baseURL, AGENTFLOW_ICONS } from '@/store/constant'
// icons
import { IconPlus, IconLayoutGrid, IconList } from '@tabler/icons-react'
@@ -38,12 +38,14 @@ const Agentflows = () => {
const [isLoading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [images, setImages] = useState({})
const [icons, setIcons] = useState({})
const [search, setSearch] = useState('')
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [loginDialogProps, setLoginDialogProps] = useState({})
const getAllAgentflows = useApi(chatflowsApi.getAllAgentflows)
const [view, setView] = useState(localStorage.getItem('flowDisplayStyle') || 'card')
const [agentflowVersion, setAgentflowVersion] = useState(localStorage.getItem('agentFlowVersion') || 'v2')
const handleChange = (event, nextView) => {
if (nextView === null) return
@@ -51,6 +53,13 @@ const Agentflows = () => {
setView(nextView)
}
const handleVersionChange = (event, nextView) => {
if (nextView === null) return
localStorage.setItem('agentFlowVersion', nextView)
setAgentflowVersion(nextView)
getAllAgentflows.request(nextView === 'v2' ? 'AGENTFLOW' : 'MULTIAGENT')
}
const onSearchChange = (event) => {
setSearch(event.target.value)
}
@@ -70,15 +79,23 @@ const Agentflows = () => {
}
const addNew = () => {
navigate('/agentcanvas')
if (agentflowVersion === 'v2') {
navigate('/v2/agentcanvas')
} else {
navigate('/agentcanvas')
}
}
const goToCanvas = (selectedAgentflow) => {
navigate(`/agentcanvas/${selectedAgentflow.id}`)
if (selectedAgentflow.type === 'AGENTFLOW') {
navigate(`/v2/agentcanvas/${selectedAgentflow.id}`)
} else {
navigate(`/agentcanvas/${selectedAgentflow.id}`)
}
}
useEffect(() => {
getAllAgentflows.request()
getAllAgentflows.request(agentflowVersion === 'v2' ? 'AGENTFLOW' : 'MULTIAGENT')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -106,19 +123,27 @@ const Agentflows = () => {
try {
const agentflows = getAllAgentflows.data
const images = {}
const icons = {}
for (let i = 0; i < agentflows.length; i += 1) {
const flowDataStr = agentflows[i].flowData
const flowData = JSON.parse(flowDataStr)
const nodes = flowData.nodes || []
images[agentflows[i].id] = []
icons[agentflows[i].id] = []
for (let j = 0; j < nodes.length; j += 1) {
const imageSrc = `${baseURL}/api/v1/node-icon/${nodes[j].data.name}`
if (!images[agentflows[i].id].includes(imageSrc)) {
images[agentflows[i].id].push(imageSrc)
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === nodes[j].data.name)
if (foundIcon) {
icons[agentflows[i].id].push(foundIcon)
} else {
const imageSrc = `${baseURL}/api/v1/node-icon/${nodes[j].data.name}`
if (!images[agentflows[i].id].includes(imageSrc)) {
images[agentflows[i].id].push(imageSrc)
}
}
}
}
setImages(images)
setIcons(icons)
} catch (e) {
console.error(e)
}
@@ -131,7 +156,46 @@ const Agentflows = () => {
<ErrorBoundary error={error} />
) : (
<Stack flexDirection='column' sx={{ gap: 3 }}>
<ViewHeader onSearchChange={onSearchChange} search={true} searchPlaceholder='Search Name or Category' title='Agents'>
<ViewHeader
onSearchChange={onSearchChange}
search={true}
searchPlaceholder='Search Name or Category'
title='Agentflows'
description='Multi-agent systems, workflow orchestration'
>
<ToggleButtonGroup
sx={{ borderRadius: 2, maxHeight: 40 }}
value={agentflowVersion}
color='primary'
exclusive
onChange={handleVersionChange}
>
<ToggleButton
sx={{
borderColor: theme.palette.grey[900] + 25,
borderRadius: 2,
color: theme?.customization?.isDarkMode ? 'white' : 'inherit'
}}
variant='contained'
value='v2'
title='V2'
>
<Chip sx={{ mr: 1 }} label='NEW' size='small' color='primary' />
V2
</ToggleButton>
<ToggleButton
sx={{
borderColor: theme.palette.grey[900] + 25,
borderRadius: 2,
color: theme?.customization?.isDarkMode ? 'white' : 'inherit'
}}
variant='contained'
value='v1'
title='V1'
>
V1
</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
sx={{ borderRadius: 2, maxHeight: 40 }}
value={view}
@@ -179,7 +243,13 @@ const Agentflows = () => {
) : (
<Box display='grid' gridTemplateColumns='repeat(3, 1fr)' gap={gridSpacing}>
{getAllAgentflows.data?.filter(filterFlows).map((data, index) => (
<ItemCard key={index} onClick={() => goToCanvas(data)} data={data} images={images[data.id]} />
<ItemCard
key={index}
onClick={() => goToCanvas(data)}
data={data}
images={images[data.id]}
icons={icons[data.id]}
/>
))}
</Box>
)}
@@ -189,6 +259,7 @@ const Agentflows = () => {
isAgentCanvas={true}
data={getAllAgentflows.data}
images={images}
icons={icons}
isLoading={isLoading}
filterFunction={filterFlows}
updateFlowsApi={getAllAgentflows}
@@ -0,0 +1,109 @@
import { EdgeLabelRenderer, getBezierPath } from 'reactflow'
import { memo } from 'react'
import PropTypes from 'prop-types'
function EdgeLabel({ transform, isHumanInput, label, color }) {
return (
<div
style={{
position: 'absolute',
background: 'transparent',
left: isHumanInput ? 10 : 0,
paddingTop: 1,
color: color,
fontSize: '0.5rem',
fontWeight: 700,
transform,
zIndex: 1000
}}
className='nodrag nopan'
>
{label}
</div>
)
}
EdgeLabel.propTypes = {
transform: PropTypes.string,
isHumanInput: PropTypes.bool,
label: PropTypes.string,
color: PropTypes.string
}
const AgentFlowEdge = ({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, selected }) => {
const xEqual = sourceX === targetX
const yEqual = sourceY === targetY
const [edgePath] = getBezierPath({
// we need this little hack in order to display the gradient for a straight line
sourceX: xEqual ? sourceX + 0.0001 : sourceX,
sourceY: yEqual ? sourceY + 0.0001 : sourceY,
sourcePosition,
targetX,
targetY,
targetPosition
})
const gradientId = `edge-gradient-${id}`
return (
<>
<defs>
<linearGradient id={gradientId}>
<stop offset='0%' stopColor={data?.sourceColor || '#ae53ba'} />
<stop offset='100%' stopColor={data?.targetColor || '#2a8af6'} />
</linearGradient>
</defs>
<path
id={`${id}-selector`}
className='agent-flow-edge-selector'
style={{
stroke: 'transparent',
strokeWidth: 15,
fill: 'none',
cursor: 'pointer'
}}
d={edgePath}
/>
<path
id={id}
className='agent-flow-edge'
style={{
strokeWidth: selected ? 3 : 2,
stroke: `url(#${gradientId})`,
filter: selected ? 'drop-shadow(0 0 3px rgba(0,0,0,0.3))' : 'none',
cursor: 'pointer',
opacity: selected ? 1 : 0.75,
fill: 'none'
}}
d={edgePath}
markerEnd={markerEnd}
/>
{data?.edgeLabel && (
<EdgeLabelRenderer>
<EdgeLabel
isHumanInput={data?.isHumanInput}
color={data?.sourceColor || '#ae53ba'}
label={data.edgeLabel}
transform={`translate(-50%, 0%) translate(${sourceX}px,${sourceY}px)`}
/>
</EdgeLabelRenderer>
)}
</>
)
}
AgentFlowEdge.propTypes = {
id: PropTypes.string,
sourceX: PropTypes.number,
sourceY: PropTypes.number,
targetX: PropTypes.number,
targetY: PropTypes.number,
sourcePosition: PropTypes.any,
targetPosition: PropTypes.any,
style: PropTypes.object,
data: PropTypes.object,
markerEnd: PropTypes.any,
selected: PropTypes.bool
}
export default memo(AgentFlowEdge)
@@ -0,0 +1,484 @@
import PropTypes from 'prop-types'
import { useContext, memo, useRef, useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import { Handle, Position, useUpdateNodeInternals, NodeToolbar } from 'reactflow'
// material-ui
import { styled, useTheme, alpha, darken, lighten } from '@mui/material/styles'
import { ButtonGroup, Avatar, Box, Typography, IconButton, Tooltip } from '@mui/material'
// project imports
import MainCard from '@/ui-component/cards/MainCard'
import { flowContext } from '@/store/context/ReactFlowContext'
import NodeInfoDialog from '@/ui-component/dialog/NodeInfoDialog'
// icons
import {
IconCheck,
IconExclamationMark,
IconCircleChevronRightFilled,
IconCopy,
IconTrash,
IconInfoCircle,
IconLoader
} from '@tabler/icons-react'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import CancelIcon from '@mui/icons-material/Cancel'
// const
import { baseURL, AGENTFLOW_ICONS } from '@/store/constant'
const CardWrapper = styled(MainCard)(({ theme }) => ({
background: theme.palette.card.main,
color: theme.darkTextPrimary,
border: 'solid 1px',
width: 'max-content',
height: 'auto',
padding: '10px',
boxShadow: 'none'
}))
const StyledNodeToolbar = styled(NodeToolbar)(({ theme }) => ({
backgroundColor: theme.palette.card.main,
color: theme.darkTextPrimary,
padding: '5px',
borderRadius: '10px',
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)'
}))
// ===========================|| CANVAS NODE ||=========================== //
const AgentFlowNode = ({ data }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const ref = useRef(null)
const updateNodeInternals = useUpdateNodeInternals()
// eslint-disable-next-line
const [position, setPosition] = useState(0)
const [isHovered, setIsHovered] = useState(false)
const { deleteNode, duplicateNode } = useContext(flowContext)
const [showInfoDialog, setShowInfoDialog] = useState(false)
const [infoDialogProps, setInfoDialogProps] = useState({})
const defaultColor = '#666666' // fallback color if data.color is not present
const nodeColor = data.color || defaultColor
// Get different shades of the color based on state
const getStateColor = () => {
if (data.selected) return nodeColor
if (isHovered) return alpha(nodeColor, 0.8)
return alpha(nodeColor, 0.5)
}
const getOutputAnchors = () => {
return data.outputAnchors ?? []
}
const getAnchorPosition = (index) => {
const currentHeight = ref.current?.clientHeight || 0
const spacing = currentHeight / (getOutputAnchors().length + 1)
const position = spacing * (index + 1)
// Update node internals when we get a non-zero position
if (position > 0) {
updateNodeInternals(data.id)
}
return position
}
const getMinimumHeight = () => {
const outputCount = getOutputAnchors().length
// Use exactly 60px as minimum height
return Math.max(60, outputCount * 20 + 40)
}
const getBackgroundColor = () => {
if (customization.isDarkMode) {
return isHovered ? darken(nodeColor, 0.7) : darken(nodeColor, 0.8)
}
return isHovered ? lighten(nodeColor, 0.8) : lighten(nodeColor, 0.9)
}
const getStatusBackgroundColor = (status) => {
switch (status) {
case 'ERROR':
return theme.palette.error.dark
case 'INPROGRESS':
return theme.palette.warning.dark
case 'STOPPED':
case 'TERMINATED':
return theme.palette.error.main
case 'FINISHED':
return theme.palette.success.dark
default:
return theme.palette.primary.dark
}
}
const renderIcon = (node) => {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (!foundIcon) return null
return <foundIcon.icon size={24} color={'white'} />
}
useEffect(() => {
if (ref.current) {
setTimeout(() => {
setPosition(ref.current?.offsetTop + ref.current?.clientHeight / 2)
updateNodeInternals(data.id)
}, 10)
}
}, [data, ref, updateNodeInternals])
return (
<div ref={ref} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
<StyledNodeToolbar>
<ButtonGroup sx={{ gap: 1 }} variant='outlined' aria-label='Basic button group'>
{data.name !== 'startAgentflow' && (
<IconButton
size={'small'}
title='Duplicate'
onClick={() => {
duplicateNode(data.id)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.primary.main
}
}}
>
<IconCopy size={20} />
</IconButton>
)}
<IconButton
size={'small'}
title='Delete'
onClick={() => {
deleteNode(data.id)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.error.main
}
}}
>
<IconTrash size={20} />
</IconButton>
<IconButton
size={'small'}
title='Info'
onClick={() => {
setInfoDialogProps({ data })
setShowInfoDialog(true)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.info.main
}
}}
>
<IconInfoCircle size={20} />
</IconButton>
</ButtonGroup>
</StyledNodeToolbar>
<CardWrapper
content={false}
sx={{
borderColor: getStateColor(),
borderWidth: '1px',
boxShadow: data.selected ? `0 0 0 1px ${getStateColor()} !important` : 'none',
minHeight: getMinimumHeight(),
height: 'auto',
backgroundColor: getBackgroundColor(),
display: 'flex',
alignItems: 'center',
'&:hover': {
boxShadow: data.selected ? `0 0 0 1px ${getStateColor()} !important` : 'none'
}
}}
border={false}
>
{data && data.status && (
<Tooltip title={data.status === 'ERROR' ? data.error || 'Error' : ''}>
<Avatar
variant='rounded'
sx={{
...theme.typography.smallAvatar,
borderRadius: '50%',
background:
data.status === 'STOPPED' || data.status === 'TERMINATED'
? 'white'
: getStatusBackgroundColor(data.status),
color: 'white',
ml: 2,
position: 'absolute',
top: -10,
right: -10
}}
>
{data.status === 'INPROGRESS' ? (
<IconLoader className='spin-animation' />
) : data.status === 'ERROR' ? (
<IconExclamationMark />
) : data.status === 'TERMINATED' ? (
<CancelIcon sx={{ color: getStatusBackgroundColor(data.status) }} />
) : data.status === 'STOPPED' ? (
<StopCircleIcon sx={{ color: getStatusBackgroundColor(data.status) }} />
) : (
<IconCheck />
)}
</Avatar>
</Tooltip>
)}
<Box sx={{ width: '100%' }}>
{!data.hideInput && (
<Handle
type='target'
position={Position.Left}
id={data.id}
style={{
width: 5,
height: 20,
backgroundColor: 'transparent',
border: 'none',
position: 'absolute',
left: -2
}}
>
<div
style={{
width: 5,
height: 20,
backgroundColor: nodeColor,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
</Handle>
)}
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Box item style={{ width: 50 }}>
{data.color && !data.icon ? (
<div
style={{
...theme.typography.commonAvatar,
...theme.typography.largeAvatar,
borderRadius: '15px',
backgroundColor: data.color,
cursor: 'grab',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: data.color
}}
>
{renderIcon(data)}
</div>
) : (
<div
style={{
...theme.typography.commonAvatar,
...theme.typography.largeAvatar,
borderRadius: '50%',
backgroundColor: 'white',
cursor: 'grab'
}}
>
<img
style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }}
src={`${baseURL}/api/v1/node-icon/${data.name}`}
alt={data.name}
/>
</div>
)}
</Box>
<Box>
<Typography
sx={{
fontSize: '0.85rem',
fontWeight: 500
}}
>
{data.label}
</Typography>
{(() => {
// Array of model configs to check and render
const modelConfigs = [
{ model: data.inputs?.llmModel, config: data.inputs?.llmModelConfig },
{ model: data.inputs?.agentModel, config: data.inputs?.agentModelConfig },
{ model: data.inputs?.conditionAgentModel, config: data.inputs?.conditionAgentModelConfig }
]
// Filter out undefined models and render each valid one
return modelConfigs
.filter((item) => item.model && item.config)
.map((item, index) => (
<Box key={`model-${index}`} sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Box
sx={{
backgroundColor: customization.isDarkMode
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(255, 255, 255, 0.9)',
borderRadius: '16px',
width: 'max-content',
height: 24,
pl: 1,
pr: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
<img
style={{ width: 20, height: 20, objectFit: 'contain' }}
src={`${baseURL}/api/v1/node-icon/${item.model}`}
alt={item.model}
/>
<Typography sx={{ fontSize: '0.7rem', ml: 0.5 }}>
{item.config.modelName || item.config.model}
</Typography>
</Box>
</Box>
))
})()}
{(() => {
// Array of tool configurations to check and render
const toolConfigs = [
{ tools: data.inputs?.llmTools, toolProperty: 'llmSelectedTool' },
{ tools: data.inputs?.agentTools, toolProperty: 'agentSelectedTool' },
{
tools: data.inputs?.selectedTool ? [{ selectedTool: data.inputs?.selectedTool }] : [],
toolProperty: 'selectedTool'
},
{ tools: data.inputs?.agentKnowledgeVSEmbeddings, toolProperty: ['vectorStore', 'embeddingModel'] }
]
// Filter out undefined tools and render each valid collection
return toolConfigs
.filter((config) => config.tools && config.tools.length > 0)
.map((config, configIndex) => (
<Box key={`tools-${configIndex}`} sx={{ display: 'flex', gap: 1, mt: 1 }}>
{config.tools.flatMap((tool, toolIndex) => {
if (Array.isArray(config.toolProperty)) {
return config.toolProperty
.filter((prop) => tool[prop])
.map((prop, propIndex) => {
const toolName = tool[prop]
return (
<Box
key={`tool-${configIndex}-${toolIndex}-${propIndex}`}
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: '50%',
width: 24,
height: 24,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '4px'
}}
>
<img
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
src={`${baseURL}/api/v1/node-icon/${toolName}`}
alt={toolName}
/>
</Box>
)
})
} else {
const toolName = tool[config.toolProperty]
if (!toolName) return []
return [
<Box
key={`tool-${configIndex}-${toolIndex}`}
sx={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: '50%',
width: 24,
height: 24,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '4px'
}}
>
<img
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
src={`${baseURL}/api/v1/node-icon/${toolName}`}
alt={toolName}
/>
</Box>
]
}
})}
</Box>
))
})()}
</Box>
</div>
{getOutputAnchors().map((outputAnchor, index) => {
return (
<Handle
type='source'
position={Position.Right}
key={outputAnchor.id}
id={outputAnchor.id}
style={{
height: 20,
width: 20,
top: getAnchorPosition(index),
backgroundColor: 'transparent',
border: 'none',
position: 'absolute',
right: -10,
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s'
}}
>
<div
style={{
position: 'absolute',
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: theme.palette.background.paper, // or 'white'
pointerEvents: 'none'
}}
/>
<IconCircleChevronRightFilled
size={20}
color={nodeColor}
style={{
pointerEvents: 'none',
position: 'relative',
zIndex: 1
}}
/>
</Handle>
)
})}
</Box>
</CardWrapper>
<NodeInfoDialog show={showInfoDialog} dialogProps={infoDialogProps} onCancel={() => setShowInfoDialog(false)}></NodeInfoDialog>
</div>
)
}
AgentFlowNode.propTypes = {
data: PropTypes.object
}
export default memo(AgentFlowNode)
@@ -0,0 +1,786 @@
import { useEffect, useRef, useState, useCallback, useContext } from 'react'
import ReactFlow, { addEdge, Controls, MiniMap, Background, useNodesState, useEdgesState } from 'reactflow'
import 'reactflow/dist/style.css'
import './index.css'
import { useReward } from 'react-rewards'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate, useLocation } from 'react-router-dom'
import {
REMOVE_DIRTY,
SET_DIRTY,
SET_CHATFLOW,
enqueueSnackbar as enqueueSnackbarAction,
closeSnackbar as closeSnackbarAction
} from '@/store/actions'
import { omit, cloneDeep } from 'lodash'
// material-ui
import { Toolbar, Box, AppBar, Button, Fab } from '@mui/material'
import { useTheme } from '@mui/material/styles'
// project imports
import CanvasNode from './AgentFlowNode'
import IterationNode from './IterationNode'
import AgentFlowEdge from './AgentFlowEdge'
import ConnectionLine from './ConnectionLine'
import StickyNote from './StickyNote'
import CanvasHeader from '@/views/canvas/CanvasHeader'
import AddNodes from '@/views/canvas/AddNodes'
import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog'
import EditNodeDialog from '@/views/agentflowsv2/EditNodeDialog'
import ChatPopUp from '@/views/chatmessage/ChatPopUp'
import ValidationPopUp from '@/views/chatmessage/ValidationPopUp'
import { flowContext } from '@/store/context/ReactFlowContext'
// API
import nodesApi from '@/api/nodes'
import chatflowsApi from '@/api/chatflows'
// Hooks
import useApi from '@/hooks/useApi'
import useConfirm from '@/hooks/useConfirm'
// icons
import { IconX, IconRefreshAlert } from '@tabler/icons-react'
// utils
import {
getUniqueNodeLabel,
getUniqueNodeId,
initNode,
updateOutdatedNodeData,
updateOutdatedNodeEdge,
isValidConnectionAgentflowV2
} from '@/utils/genericHelper'
import useNotifier from '@/utils/useNotifier'
import { usePrompt } from '@/utils/usePrompt'
// const
import { FLOWISE_CREDENTIAL_ID, AGENTFLOW_ICONS } from '@/store/constant'
const nodeTypes = { agentFlow: CanvasNode, stickyNote: StickyNote, iteration: IterationNode }
const edgeTypes = { agentFlow: AgentFlowEdge }
// ==============================|| CANVAS ||============================== //
const AgentflowCanvas = () => {
const theme = useTheme()
const navigate = useNavigate()
const customization = useSelector((state) => state.customization)
const { state } = useLocation()
const templateFlowData = state ? state.templateFlowData : ''
const URLpath = document.location.pathname.toString().split('/')
const chatflowId =
URLpath[URLpath.length - 1] === 'canvas' || URLpath[URLpath.length - 1] === 'agentcanvas' ? '' : URLpath[URLpath.length - 1]
const canvasTitle = URLpath.includes('agentcanvas') ? 'Agent' : 'Chatflow'
const { confirm } = useConfirm()
const dispatch = useDispatch()
const canvas = useSelector((state) => state.canvas)
const [canvasDataStore, setCanvasDataStore] = useState(canvas)
const [chatflow, setChatflow] = useState(null)
const { reactFlowInstance, setReactFlowInstance } = useContext(flowContext)
// ==============================|| Snackbar ||============================== //
useNotifier()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
// ==============================|| ReactFlow ||============================== //
const [nodes, setNodes, onNodesChange] = useNodesState()
const [edges, setEdges, onEdgesChange] = useEdgesState()
const [selectedNode, setSelectedNode] = useState(null)
const [isSyncNodesButtonEnabled, setIsSyncNodesButtonEnabled] = useState(false)
const [editNodeDialogOpen, setEditNodeDialogOpen] = useState(false)
const [editNodeDialogProps, setEditNodeDialogProps] = useState({})
const reactFlowWrapper = useRef(null)
// ==============================|| Chatflow API ||============================== //
const getNodesApi = useApi(nodesApi.getAllNodes)
const createNewChatflowApi = useApi(chatflowsApi.createNewChatflow)
const updateChatflowApi = useApi(chatflowsApi.updateChatflow)
const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow)
// ==============================|| Events & Actions ||============================== //
const onConnect = (params) => {
if (!isValidConnectionAgentflowV2(params, reactFlowInstance)) {
return
}
const nodeName = params.sourceHandle.split('_')[0]
const targetNodeName = params.targetHandle.split('_')[0]
const targetColor = AGENTFLOW_ICONS.find((icon) => icon.name === targetNodeName)?.color ?? theme.palette.primary.main
const sourceColor = AGENTFLOW_ICONS.find((icon) => icon.name === nodeName)?.color ?? theme.palette.primary.main
let edgeLabel = undefined
if (nodeName === 'conditionAgentflow' || nodeName === 'conditionAgentAgentflow') {
const _edgeLabel = params.sourceHandle.split('-').pop()
edgeLabel = (isNaN(_edgeLabel) ? 0 : _edgeLabel).toString()
}
if (nodeName === 'humanInputAgentflow') {
edgeLabel = params.sourceHandle.split('-').pop()
edgeLabel = edgeLabel === '0' ? 'proceed' : 'reject'
}
// Check if both source and target nodes are within the same iteration node
const sourceNode = reactFlowInstance.getNodes().find((node) => node.id === params.source)
const targetNode = reactFlowInstance.getNodes().find((node) => node.id === params.target)
const isWithinIterationNode = sourceNode?.parentNode && targetNode?.parentNode && sourceNode.parentNode === targetNode.parentNode
const newEdge = {
...params,
data: {
...params.data,
sourceColor,
targetColor,
edgeLabel,
isHumanInput: nodeName === 'humanInputAgentflow'
},
...(isWithinIterationNode && { zIndex: 9999 }),
type: 'agentFlow',
id: `${params.source}-${params.sourceHandle}-${params.target}-${params.targetHandle}`
}
setEdges((eds) => addEdge(newEdge, eds))
}
const handleLoadFlow = (file) => {
try {
const flowData = JSON.parse(file)
const nodes = flowData.nodes || []
setNodes(nodes)
setEdges(flowData.edges || [])
setTimeout(() => setDirty(), 0)
} catch (e) {
console.error(e)
}
}
const handleDeleteFlow = async () => {
const confirmPayload = {
title: `Delete`,
description: `Delete ${canvasTitle} ${chatflow.name}?`,
confirmButtonName: 'Delete',
cancelButtonName: 'Cancel'
}
const isConfirmed = await confirm(confirmPayload)
if (isConfirmed) {
try {
await chatflowsApi.deleteChatflow(chatflow.id)
localStorage.removeItem(`${chatflow.id}_INTERNAL`)
navigate('/agentflows')
} catch (error) {
enqueueSnackbar({
message: typeof error.response.data === 'object' ? error.response.data.message : error.response.data,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}
}
const handleSaveFlow = (chatflowName) => {
if (reactFlowInstance) {
const nodes = reactFlowInstance.getNodes().map((node) => {
const nodeData = cloneDeep(node.data)
if (Object.prototype.hasOwnProperty.call(nodeData.inputs, FLOWISE_CREDENTIAL_ID)) {
nodeData.credential = nodeData.inputs[FLOWISE_CREDENTIAL_ID]
nodeData.inputs = omit(nodeData.inputs, [FLOWISE_CREDENTIAL_ID])
}
node.data = {
...nodeData,
selected: false,
status: undefined
}
return node
})
const rfInstanceObject = reactFlowInstance.toObject()
rfInstanceObject.nodes = nodes
const flowData = JSON.stringify(rfInstanceObject)
if (!chatflow.id) {
const newChatflowBody = {
name: chatflowName,
deployed: false,
isPublic: false,
flowData,
type: 'AGENTFLOW'
}
createNewChatflowApi.request(newChatflowBody)
} else {
const updateBody = {
name: chatflowName,
flowData
}
updateChatflowApi.request(chatflow.id, updateBody)
}
}
}
// eslint-disable-next-line
const onNodeClick = useCallback((event, clickedNode) => {
setSelectedNode(clickedNode)
setNodes((nds) =>
nds.map((node) => {
if (node.id === clickedNode.id) {
node.data = {
...node.data,
selected: true
}
} else {
node.data = {
...node.data,
selected: false
}
}
return node
})
)
})
// eslint-disable-next-line
const onNodeDoubleClick = useCallback((event, node) => {
if (!node || !node.data) return
if (node.data.name === 'stickyNoteAgentflow') {
// dont show dialog
} else {
const dialogProps = {
data: node.data,
inputParams: node.data.inputParams.filter((inputParam) => !inputParam.hidden)
}
setEditNodeDialogProps(dialogProps)
setEditNodeDialogOpen(true)
}
})
const onDragOver = useCallback((event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}, [])
const onDrop = useCallback(
(event) => {
event.preventDefault()
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect()
let nodeData = event.dataTransfer.getData('application/reactflow')
// check if the dropped element is valid
if (typeof nodeData === 'undefined' || !nodeData) {
return
}
nodeData = JSON.parse(nodeData)
const position = reactFlowInstance.project({
x: event.clientX - reactFlowBounds.left - 100,
y: event.clientY - reactFlowBounds.top - 50
})
const nodes = reactFlowInstance.getNodes()
if (nodeData.name === 'startAgentflow' && nodes.find((node) => node.data.name === 'startAgentflow')) {
enqueueSnackbar({
message: 'Only one start node is allowed',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
return
}
const newNodeId = getUniqueNodeId(nodeData, reactFlowInstance.getNodes())
const newNodeLabel = getUniqueNodeLabel(nodeData, nodes)
const newNode = {
id: newNodeId,
position,
data: { ...initNode(nodeData, newNodeId, true), label: newNodeLabel }
}
if (nodeData.type === 'Iteration') {
newNode.type = 'iteration'
} else if (nodeData.type === 'StickyNote') {
newNode.type = 'stickyNote'
} else {
newNode.type = 'agentFlow'
}
// Check if the dropped node is within any Iteration node's flowContainerSize
const iterationNodes = nodes.filter((node) => node.type === 'iteration')
let parentNode = null
for (const iterationNode of iterationNodes) {
// Get the iteration node's position and dimensions
const nodeWidth = iterationNode.width || 300
const nodeHeight = iterationNode.height || 250
// Calculate the boundaries of the iteration node
const nodeLeft = iterationNode.position.x
const nodeRight = nodeLeft + nodeWidth
const nodeTop = iterationNode.position.y
const nodeBottom = nodeTop + nodeHeight
// Check if the dropped position is within these boundaries
if (position.x >= nodeLeft && position.x <= nodeRight && position.y >= nodeTop && position.y <= nodeBottom) {
parentNode = iterationNode
// We can't have nested iteration nodes
if (nodeData.name === 'iterationAgentflow') {
enqueueSnackbar({
message: 'Nested iteration node is not supported yet',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
return
}
// We can't have human input node inside iteration node
if (nodeData.name === 'humanInputAgentflow') {
enqueueSnackbar({
message: 'Human input node is not supported inside Iteration node',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
return
}
break
}
}
// If the node is dropped inside an iteration node, set its parent
if (parentNode) {
newNode.parentNode = parentNode.id
newNode.extent = 'parent'
// Adjust position to be relative to the parent
newNode.position = {
x: position.x - parentNode.position.x,
y: position.y - parentNode.position.y
}
}
setSelectedNode(newNode)
setNodes((nds) => {
return (nds ?? []).concat(newNode).map((node) => {
if (node.id === newNode.id) {
node.data = {
...node.data,
selected: true
}
} else {
node.data = {
...node.data,
selected: false
}
}
return node
})
})
setTimeout(() => setDirty(), 0)
},
// eslint-disable-next-line
[reactFlowInstance]
)
const syncNodes = () => {
const componentNodes = canvas.componentNodes
const cloneNodes = cloneDeep(nodes)
const cloneEdges = cloneDeep(edges)
let toBeRemovedEdges = []
for (let i = 0; i < cloneNodes.length; i++) {
const node = cloneNodes[i]
const componentNode = componentNodes.find((cn) => cn.name === node.data.name)
if (componentNode && componentNode.version > node.data.version) {
const clonedComponentNode = cloneDeep(componentNode)
cloneNodes[i].data = updateOutdatedNodeData(clonedComponentNode, node.data, true)
toBeRemovedEdges.push(...updateOutdatedNodeEdge(cloneNodes[i].data, cloneEdges))
}
}
setNodes(cloneNodes)
setEdges(cloneEdges.filter((edge) => !toBeRemovedEdges.includes(edge)))
setDirty()
setIsSyncNodesButtonEnabled(false)
}
const { reward: confettiReward } = useReward('canvasConfetti', 'confetti', {
elementCount: 150,
spread: 80,
lifetime: 300,
startVelocity: 40,
zIndex: 10000,
decay: 0.92,
position: 'fixed'
})
const triggerConfetti = () => {
setTimeout(() => {
confettiReward()
}, 50)
}
const saveChatflowSuccess = () => {
dispatch({ type: REMOVE_DIRTY })
enqueueSnackbar({
message: `${canvasTitle} saved`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
const errorFailed = (message) => {
enqueueSnackbar({
message,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
const setDirty = () => {
dispatch({ type: SET_DIRTY })
}
const checkIfSyncNodesAvailable = (nodes) => {
const componentNodes = canvas.componentNodes
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
const componentNode = componentNodes.find((cn) => cn.name === node.data.name)
if (componentNode && componentNode.version > node.data.version) {
setIsSyncNodesButtonEnabled(true)
return
}
}
setIsSyncNodesButtonEnabled(false)
}
// ==============================|| useEffect ||============================== //
// Get specific chatflow successful
useEffect(() => {
if (getSpecificChatflowApi.data) {
const chatflow = getSpecificChatflowApi.data
const initialFlow = chatflow.flowData ? JSON.parse(chatflow.flowData) : []
setNodes(initialFlow.nodes || [])
setEdges(initialFlow.edges || [])
dispatch({ type: SET_CHATFLOW, chatflow })
} else if (getSpecificChatflowApi.error) {
errorFailed(`Failed to retrieve ${canvasTitle}: ${getSpecificChatflowApi.error.response.data.message}`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getSpecificChatflowApi.data, getSpecificChatflowApi.error])
// Create new chatflow successful
useEffect(() => {
if (createNewChatflowApi.data) {
const chatflow = createNewChatflowApi.data
dispatch({ type: SET_CHATFLOW, chatflow })
saveChatflowSuccess()
window.history.replaceState(state, null, `/v2/agentcanvas/${chatflow.id}`)
} else if (createNewChatflowApi.error) {
errorFailed(`Failed to save ${canvasTitle}: ${createNewChatflowApi.error.response.data.message}`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [createNewChatflowApi.data, createNewChatflowApi.error])
// Update chatflow successful
useEffect(() => {
if (updateChatflowApi.data) {
dispatch({ type: SET_CHATFLOW, chatflow: updateChatflowApi.data })
saveChatflowSuccess()
} else if (updateChatflowApi.error) {
errorFailed(`Failed to save ${canvasTitle}: ${updateChatflowApi.error.response.data.message}`)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateChatflowApi.data, updateChatflowApi.error])
useEffect(() => {
setChatflow(canvasDataStore.chatflow)
if (canvasDataStore.chatflow) {
const flowData = canvasDataStore.chatflow.flowData ? JSON.parse(canvasDataStore.chatflow.flowData) : []
checkIfSyncNodesAvailable(flowData.nodes || [])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canvasDataStore.chatflow])
// Initialization
useEffect(() => {
setIsSyncNodesButtonEnabled(false)
if (chatflowId) {
getSpecificChatflowApi.request(chatflowId)
} else {
if (localStorage.getItem('duplicatedFlowData')) {
handleLoadFlow(localStorage.getItem('duplicatedFlowData'))
setTimeout(() => localStorage.removeItem('duplicatedFlowData'), 0)
} else {
setNodes([])
setEdges([])
}
dispatch({
type: SET_CHATFLOW,
chatflow: {
name: `Untitled ${canvasTitle}`
}
})
}
getNodesApi.request()
// Clear dirty state before leaving and remove any ongoing test triggers and webhooks
return () => {
setTimeout(() => dispatch({ type: REMOVE_DIRTY }), 0)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
setCanvasDataStore(canvas)
}, [canvas])
useEffect(() => {
function handlePaste(e) {
const pasteData = e.clipboardData.getData('text')
//TODO: prevent paste event when input focused, temporary fix: catch chatflow syntax
if (pasteData.includes('{"nodes":[') && pasteData.includes('],"edges":[')) {
handleLoadFlow(pasteData)
}
}
window.addEventListener('paste', handlePaste)
return () => {
window.removeEventListener('paste', handlePaste)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (templateFlowData && templateFlowData.includes('"nodes":[') && templateFlowData.includes('],"edges":[')) {
handleLoadFlow(templateFlowData)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateFlowData])
usePrompt('You have unsaved changes! Do you want to navigate away?', canvasDataStore.isDirty)
const [chatPopupOpen, setChatPopupOpen] = useState(false)
useEffect(() => {
if (!chatflowId && !localStorage.getItem('duplicatedFlowData') && getNodesApi.data && nodes.length === 0) {
const startNodeData = getNodesApi.data.find((node) => node.name === 'startAgentflow')
if (startNodeData) {
const clonedStartNodeData = cloneDeep(startNodeData)
clonedStartNodeData.position = { x: 100, y: 100 }
const startNode = {
id: 'startAgentflow_0',
type: 'agentFlow',
position: { x: 100, y: 100 },
data: {
...initNode(clonedStartNodeData, 'startAgentflow_0', true),
label: 'Start'
}
}
setNodes([startNode])
setEdges([])
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getNodesApi.data, chatflowId])
return (
<>
<span
id='canvasConfetti'
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '0',
height: '0',
zIndex: 9999,
pointerEvents: 'none',
background: 'transparent'
}}
/>
<Box>
<AppBar
enableColorOnDark
position='fixed'
color='inherit'
elevation={1}
sx={{
bgcolor: theme.palette.background.default
}}
>
<Toolbar>
<CanvasHeader
chatflow={chatflow}
handleSaveFlow={handleSaveFlow}
handleDeleteFlow={handleDeleteFlow}
handleLoadFlow={handleLoadFlow}
isAgentCanvas={true}
isAgentflowV2={true}
/>
</Toolbar>
</AppBar>
<Box sx={{ pt: '70px', height: '100vh', width: '100%' }}>
<div className='reactflow-parent-wrapper'>
<div className='reactflow-wrapper' ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onEdgesChange={onEdgesChange}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeDragStop={setDirty}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={onConnect}
onInit={setReactFlowInstance}
fitView
deleteKeyCode={canvas.canvasDialogShow ? null : ['Delete']}
minZoom={0.5}
connectionLineComponent={ConnectionLine}
>
<Controls
style={{
display: 'flex',
flexDirection: 'row',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: customization.isDarkMode ? theme.palette.background.default : '#fff'
}}
/>
<MiniMap
nodeStrokeWidth={3}
nodeColor={customization.isDarkMode ? '#2d2d2d' : '#e2e2e2'}
nodeStrokeColor={customization.isDarkMode ? '#525252' : '#fff'}
maskColor={customization.isDarkMode ? 'rgb(45, 45, 45, 0.6)' : 'rgb(240, 240, 240, 0.6)'}
style={{
backgroundColor: customization.isDarkMode ? theme.palette.background.default : '#fff'
}}
/>
<Background color='#aaa' gap={16} />
<AddNodes
isAgentCanvas={true}
isAgentflowv2={true}
nodesData={getNodesApi.data}
node={selectedNode}
onFlowGenerated={triggerConfetti}
/>
<EditNodeDialog
show={editNodeDialogOpen}
dialogProps={editNodeDialogProps}
onCancel={() => setEditNodeDialogOpen(false)}
/>
{isSyncNodesButtonEnabled && (
<Fab
sx={{
left: 60,
top: 20,
color: 'white',
background: 'orange',
'&:hover': {
background: 'orange',
backgroundImage: `linear-gradient(rgb(0 0 0/10%) 0 0)`
}
}}
size='small'
aria-label='sync'
title='Sync Nodes'
onClick={() => syncNodes()}
>
<IconRefreshAlert />
</Fab>
)}
<ChatPopUp isAgentCanvas={true} chatflowid={chatflowId} onOpenChange={setChatPopupOpen} />
{!chatPopupOpen && <ValidationPopUp isAgentCanvas={true} chatflowid={chatflowId} />}
</ReactFlow>
</div>
</div>
</Box>
<ConfirmDialog />
</Box>
</>
)
}
export default AgentflowCanvas
@@ -0,0 +1,219 @@
import { useContext, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { cloneDeep } from 'lodash'
// Material
import { Accordion, AccordionSummary, AccordionDetails, Box, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { IconSettings } from '@tabler/icons-react'
// Project imports
import NodeInputHandler from '../canvas/NodeInputHandler'
// API
import nodesApi from '@/api/nodes'
// const
import { initNode, showHideInputParams, initializeDefaultNodeData } from '@/utils/genericHelper'
import { flowContext } from '@/store/context/ReactFlowContext'
import { FLOWISE_CREDENTIAL_ID } from '@/store/constant'
export const ConfigInput = ({ data, inputParam, disabled = false, arrayIndex = null, parentParamForArray = null }) => {
const theme = useTheme()
const { reactFlowInstance } = useContext(flowContext)
const [expanded, setExpanded] = useState(false)
const [selectedComponentNodeData, setSelectedComponentNodeData] = useState({})
const handleAccordionChange = (event, isExpanded) => {
setExpanded(isExpanded)
}
const onCustomDataChange = ({ inputParam, newValue }) => {
let nodeData = cloneDeep(selectedComponentNodeData)
const updatedInputs = { ...nodeData.inputs }
updatedInputs[inputParam.name] = newValue
const updatedInputParams = showHideInputParams({
...nodeData,
inputs: updatedInputs
})
// Remove inputs with display set to false
Object.keys(updatedInputs).forEach((key) => {
const input = updatedInputParams.find((param) => param.name === key)
if (input && input.display === false) {
delete updatedInputs[key]
}
})
const credential = updatedInputs.credential || updatedInputs[FLOWISE_CREDENTIAL_ID]
nodeData = {
...nodeData,
inputParams: updatedInputParams,
inputs: updatedInputs,
credential: credential ? credential : undefined
}
setSelectedComponentNodeData(nodeData)
}
// Load initial component data when the component mounts
useEffect(() => {
const loadComponentData = async () => {
// Get the node name from inputs
const nodeName = data.inputs[inputParam.name]
const node = await nodesApi.getSpecificNode(nodeName)
if (!node.data) return
// Initialize component node with basic data
const componentNodeData = cloneDeep(initNode(node.data, `${node.data.nodeName}_0`))
// Helper function to check if array-based configuration exists
const isArray = () => {
return parentParamForArray && data.inputs[parentParamForArray.name]
}
const hasArrayConfig = () => {
return (
parentParamForArray &&
data.inputs[parentParamForArray.name] &&
Array.isArray(data.inputs[parentParamForArray.name]) &&
data.inputs[parentParamForArray.name][arrayIndex] &&
data.inputs[parentParamForArray.name][arrayIndex][`${inputParam.name}Config`]
)
}
// Helper function to get current input value
const getCurrentInputValue = () => {
return hasArrayConfig() ? data.inputs[parentParamForArray.name][arrayIndex][inputParam.name] : data.inputs[inputParam.name]
}
// Helper function to get config data
const getConfigData = () => {
return hasArrayConfig()
? data.inputs[parentParamForArray.name][arrayIndex][`${inputParam.name}Config`]
: data.inputs[`${inputParam.name}Config`]
}
// Update component inputs based on configuration
if (hasArrayConfig() || data.inputs[`${inputParam.name}Config`]) {
const configData = getConfigData()
const currentValue = getCurrentInputValue()
// If stored config value doesn't match current input, reset to defaults
if (configData[inputParam.name] !== currentValue) {
const defaultInput = initializeDefaultNodeData(componentNodeData.inputParams)
componentNodeData.inputs = { ...defaultInput, [inputParam.name]: currentValue }
} else {
// Use existing config with current input value
componentNodeData.inputs = { ...configData, [inputParam.name]: currentValue }
}
} else {
const currentValue = isArray()
? data.inputs[parentParamForArray.name][arrayIndex][inputParam.name]
: data.inputs[inputParam.name]
componentNodeData.inputs = {
...componentNodeData.inputs,
[inputParam.name]: currentValue
}
}
// Update input parameters visibility based on current inputs
componentNodeData.inputParams = showHideInputParams({
...componentNodeData,
inputs: componentNodeData.inputs
})
const credential = componentNodeData.inputs.credential || componentNodeData.inputs[FLOWISE_CREDENTIAL_ID]
componentNodeData.credential = credential ? credential : undefined
setSelectedComponentNodeData(componentNodeData)
}
loadComponentData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Update node configuration when selected component data changes
useEffect(() => {
if (!selectedComponentNodeData.inputs) return
reactFlowInstance.setNodes((nds) =>
nds.map((node) => {
if (node.id !== data.id) return node
// Handle array-based configuration
if (arrayIndex !== null && parentParamForArray) {
// Initialize array if it doesn't exist
if (!node.data.inputs[parentParamForArray.name]) {
node.data.inputs[parentParamForArray.name] = []
}
// Initialize array element if it doesn't exist
if (!node.data.inputs[parentParamForArray.name][arrayIndex]) {
node.data.inputs[parentParamForArray.name][arrayIndex] = {}
}
// Store config in array
node.data.inputs[parentParamForArray.name][arrayIndex][`${inputParam.name}Config`] = selectedComponentNodeData.inputs
} else {
// Store config directly
node.data.inputs[`${inputParam.name}Config`] = selectedComponentNodeData.inputs
}
return node
})
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.inputs, arrayIndex, parentParamForArray, selectedComponentNodeData])
return (
<>
<Box
sx={{
p: 0,
mt: 1,
mb: 1,
border: 1,
borderColor: theme.palette.grey[900] + 25,
borderRadius: 2
}}
>
<Accordion sx={{ background: 'transparent' }} expanded={expanded} onChange={handleAccordionChange}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ background: 'transparent' }}>
<IconSettings stroke={1.5} size='1.3rem' />
<Typography sx={{ ml: 1 }}>{selectedComponentNodeData?.label} Parameters</Typography>
</AccordionSummary>
<AccordionDetails>
{(selectedComponentNodeData.inputParams ?? [])
.filter((inputParam) => !inputParam.hidden)
.filter((inputParam) => inputParam.display !== false)
.map((inputParam, index) => (
<NodeInputHandler
disabled={disabled}
key={index}
inputParam={inputParam}
data={selectedComponentNodeData}
isAdditionalParams={true}
onCustomDataChange={onCustomDataChange}
/>
))}
</AccordionDetails>
</Accordion>
</Box>
</>
)
}
ConfigInput.propTypes = {
name: PropTypes.string,
inputParam: PropTypes.object,
data: PropTypes.object,
disabled: PropTypes.bool,
arrayIndex: PropTypes.number,
parentParamForArray: PropTypes.object
}
@@ -0,0 +1,102 @@
import { memo } from 'react'
import { EdgeLabelRenderer, useStore, getBezierPath } from 'reactflow'
import PropTypes from 'prop-types'
import { AGENTFLOW_ICONS } from '@/store/constant'
import { useTheme } from '@mui/material/styles'
function EdgeLabel({ transform, isHumanInput, label, color }) {
return (
<div
style={{
position: 'absolute',
background: 'transparent',
left: isHumanInput ? 20 : 10,
paddingTop: 1,
color: color,
fontSize: '0.5rem',
fontWeight: 700,
transform,
zIndex: 1000
}}
className='nodrag nopan'
>
{label}
</div>
)
}
EdgeLabel.propTypes = {
transform: PropTypes.string,
isHumanInput: PropTypes.bool,
label: PropTypes.string,
color: PropTypes.string
}
const ConnectionLine = ({ fromX, fromY, toX, toY, fromPosition, toPosition }) => {
const [edgePath] = getBezierPath({
// we need this little hack in order to display the gradient for a straight line
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition,
targetX: toX,
targetY: toY,
targetPosition: toPosition
})
const { connectionHandleId } = useStore()
const theme = useTheme()
const nodeName = (connectionHandleId || '').split('_')[0] || ''
const isLabelVisible = nodeName === 'humanInputAgentflow' || nodeName === 'conditionAgentflow' || nodeName === 'conditionAgentAgentflow'
const getEdgeLabel = () => {
let edgeLabel = undefined
if (nodeName === 'conditionAgentflow' || nodeName === 'conditionAgentAgentflow') {
const _edgeLabel = connectionHandleId.split('-').pop()
edgeLabel = (isNaN(_edgeLabel) ? 0 : _edgeLabel).toString()
}
if (nodeName === 'humanInputAgentflow') {
const _edgeLabel = connectionHandleId.split('-').pop()
edgeLabel = (isNaN(_edgeLabel) ? 0 : _edgeLabel).toString()
edgeLabel = edgeLabel === '0' ? 'proceed' : 'reject'
}
return edgeLabel
}
const color =
AGENTFLOW_ICONS.find((icon) => icon.name === (connectionHandleId || '').split('_')[0] || '')?.color ?? theme.palette.primary.main
return (
<g>
<path fill='none' stroke={color} strokeWidth={1.5} className='animated' d={edgePath} />
<g transform={`translate(${toX - 10}, ${toY - 10}) scale(0.8)`}>
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
<path
d='M12 2c5.523 0 10 4.477 10 10a10 10 0 0 1 -20 0c0 -5.523 4.477 -10 10 -10m-.293 6.293a1 1 0 0 0 -1.414 0l-.083 .094a1 1 0 0 0 .083 1.32l2.292 2.293l-2.292 2.293a1 1 0 0 0 1.414 1.414l3 -3a1 1 0 0 0 0 -1.414z'
fill={color}
/>
</g>
{isLabelVisible && (
<EdgeLabelRenderer>
<EdgeLabel
color={color}
isHumanInput={nodeName === 'humanInputAgentflow'}
label={getEdgeLabel()}
transform={`translate(-50%, 0%) translate(${fromX}px,${fromY}px)`}
/>
</EdgeLabelRenderer>
)}
</g>
)
}
ConnectionLine.propTypes = {
fromX: PropTypes.number,
fromY: PropTypes.number,
toX: PropTypes.number,
toY: PropTypes.number,
fromPosition: PropTypes.any,
toPosition: PropTypes.any
}
export default memo(ConnectionLine)
@@ -0,0 +1,279 @@
import { createPortal } from 'react-dom'
import { useDispatch, useSelector } from 'react-redux'
import { useState, useEffect, useRef, useContext, memo } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import PropTypes from 'prop-types'
import { Stack, Box, Typography, TextField, Dialog, DialogContent, ButtonBase, Avatar } from '@mui/material'
import NodeInputHandler from '@/views/canvas/NodeInputHandler'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
import { IconPencil, IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'
import { useTheme } from '@mui/material/styles'
import { flowContext } from '@/store/context/ReactFlowContext'
import { showHideInputParams } from '@/utils/genericHelper'
const EditNodeDialog = ({ show, dialogProps, onCancel }) => {
const portalElement = document.getElementById('portal')
const dispatch = useDispatch()
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const nodeNameRef = useRef()
const { reactFlowInstance } = useContext(flowContext)
const updateNodeInternals = useUpdateNodeInternals()
const [inputParams, setInputParams] = useState([])
const [data, setData] = useState({})
const [isEditingNodeName, setEditingNodeName] = useState(null)
const [nodeName, setNodeName] = useState('')
const onNodeLabelChange = () => {
reactFlowInstance.setNodes((nds) =>
nds.map((node) => {
if (node.id === data.id) {
node.data = {
...node.data,
label: nodeNameRef.current.value
}
setData(node.data)
}
return node
})
)
updateNodeInternals(data.id)
}
const onCustomDataChange = ({ nodeId, inputParam, newValue }) => {
reactFlowInstance.setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
const updatedInputs = {
...node.data.inputs,
[inputParam.name]: newValue
}
const updatedInputParams = showHideInputParams({
...node.data,
inputs: updatedInputs
})
// Remove inputs with display set to false
Object.keys(updatedInputs).forEach((key) => {
const input = updatedInputParams.find((param) => param.name === key)
if (input && input.display === false) {
delete updatedInputs[key]
}
})
node.data = {
...node.data,
inputParams: updatedInputParams,
inputs: updatedInputs
}
setInputParams(updatedInputParams)
setData(node.data)
}
return node
})
)
}
useEffect(() => {
if (dialogProps.inputParams) {
setInputParams(dialogProps.inputParams)
}
if (dialogProps.data) {
setData(dialogProps.data)
if (dialogProps.data.label) setNodeName(dialogProps.data.label)
}
return () => {
setInputParams([])
setData({})
}
}, [dialogProps])
useEffect(() => {
if (show) dispatch({ type: SHOW_CANVAS_DIALOG })
else dispatch({ type: HIDE_CANVAS_DIALOG })
return () => dispatch({ type: HIDE_CANVAS_DIALOG })
}, [show, dispatch])
const component = show ? (
<Dialog
onClose={onCancel}
open={show}
fullWidth
maxWidth='sm'
aria-labelledby='alert-dialog-title'
aria-describedby='alert-dialog-description'
>
<DialogContent>
{data && data.name && (
<Box sx={{ width: '100%' }}>
{!isEditingNodeName ? (
<Stack flexDirection='row' sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography
sx={{
ml: 2,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap'
}}
variant='h4'
>
{nodeName}
</Typography>
{data?.id && (
<ButtonBase title='Edit Name' sx={{ borderRadius: '50%' }}>
<Avatar
variant='rounded'
sx={{
...theme.typography.commonAvatar,
...theme.typography.mediumAvatar,
transition: 'all .2s ease-in-out',
ml: 1,
background: theme.palette.secondary.light,
color: theme.palette.secondary.dark,
'&:hover': {
background: theme.palette.secondary.dark,
color: theme.palette.secondary.light
}
}}
color='inherit'
onClick={() => setEditingNodeName(true)}
>
<IconPencil stroke={1.5} size='1rem' />
</Avatar>
</ButtonBase>
)}
</Stack>
) : (
<Stack flexDirection='row' sx={{ width: '100%' }}>
<TextField
//eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
size='small'
sx={{
width: '100%',
ml: 2
}}
inputRef={nodeNameRef}
defaultValue={nodeName}
onKeyDown={(e) => {
if (e.key === 'Enter') {
data.label = nodeNameRef.current.value
setNodeName(nodeNameRef.current.value)
onNodeLabelChange()
setEditingNodeName(false)
} else if (e.key === 'Escape') {
setEditingNodeName(false)
}
}}
/>
<ButtonBase title='Save Name' sx={{ borderRadius: '50%' }}>
<Avatar
variant='rounded'
sx={{
...theme.typography.commonAvatar,
...theme.typography.mediumAvatar,
transition: 'all .2s ease-in-out',
background: theme.palette.success.light,
color: theme.palette.success.dark,
ml: 1,
'&:hover': {
background: theme.palette.success.dark,
color: theme.palette.success.light
}
}}
color='inherit'
onClick={() => {
data.label = nodeNameRef.current.value
setNodeName(nodeNameRef.current.value)
onNodeLabelChange()
setEditingNodeName(false)
}}
>
<IconCheck stroke={1.5} size='1rem' />
</Avatar>
</ButtonBase>
<ButtonBase title='Cancel' sx={{ borderRadius: '50%' }}>
<Avatar
variant='rounded'
sx={{
...theme.typography.commonAvatar,
...theme.typography.mediumAvatar,
transition: 'all .2s ease-in-out',
background: theme.palette.error.light,
color: theme.palette.error.dark,
ml: 1,
'&:hover': {
background: theme.palette.error.dark,
color: theme.palette.error.light
}
}}
color='inherit'
onClick={() => setEditingNodeName(false)}
>
<IconX stroke={1.5} size='1rem' />
</Avatar>
</ButtonBase>
</Stack>
)}
</Box>
)}
{data?.hint && (
<Stack
direction='row'
alignItems='center'
sx={{
ml: 2,
backgroundColor: customization.isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)',
borderRadius: '8px',
mr: 2,
px: 1.5,
py: 1,
mt: 1,
mb: 1,
border: `1px solid ${customization.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.08)'}`
}}
>
<IconInfoCircle size='1rem' stroke={1.5} color={theme.palette.info.main} style={{ marginRight: '6px' }} />
<Typography
variant='caption'
color='text.secondary'
sx={{
fontStyle: 'italic',
lineHeight: 1.2
}}
>
{data.hint}
</Typography>
</Stack>
)}
{inputParams
.filter((inputParam) => inputParam.display !== false)
.map((inputParam, index) => (
<NodeInputHandler
disabled={dialogProps.disabled}
key={index}
inputParam={inputParam}
data={data}
isAdditionalParams={true}
onCustomDataChange={onCustomDataChange}
/>
))}
</DialogContent>
</Dialog>
) : null
return createPortal(component, portalElement)
}
EditNodeDialog.propTypes = {
show: PropTypes.bool,
dialogProps: PropTypes.object,
onCancel: PropTypes.func
}
export default memo(EditNodeDialog)
@@ -0,0 +1,425 @@
import PropTypes from 'prop-types'
import { useContext, memo, useRef, useState, useEffect, useCallback } from 'react'
import { useSelector } from 'react-redux'
import { Background, Handle, Position, useUpdateNodeInternals, NodeToolbar, NodeResizer } from 'reactflow'
// material-ui
import { styled, useTheme, alpha, darken, lighten } from '@mui/material/styles'
import { ButtonGroup, Avatar, Box, Typography, IconButton, Tooltip } from '@mui/material'
// project imports
import MainCard from '@/ui-component/cards/MainCard'
import { flowContext } from '@/store/context/ReactFlowContext'
import NodeInfoDialog from '@/ui-component/dialog/NodeInfoDialog'
// icons
import {
IconCheck,
IconExclamationMark,
IconCircleChevronRightFilled,
IconCopy,
IconTrash,
IconInfoCircle,
IconLoader
} from '@tabler/icons-react'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import CancelIcon from '@mui/icons-material/Cancel'
// const
import { baseURL, AGENTFLOW_ICONS } from '@/store/constant'
const CardWrapper = styled(MainCard)(({ theme }) => ({
background: theme.palette.card.main,
color: theme.darkTextPrimary,
border: 'solid 1px',
width: 'max-content',
height: 'auto',
padding: '10px',
boxShadow: 'none'
}))
const StyledNodeToolbar = styled(NodeToolbar)(({ theme }) => ({
backgroundColor: theme.palette.card.main,
color: theme.darkTextPrimary,
padding: '5px',
borderRadius: '10px',
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)'
}))
// ===========================|| ITERATION NODE ||=========================== //
const IterationNode = ({ data }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const ref = useRef(null)
const reactFlowWrapper = useRef(null)
const updateNodeInternals = useUpdateNodeInternals()
// eslint-disable-next-line
const [position, setPosition] = useState(0)
const [isHovered, setIsHovered] = useState(false)
const { deleteNode, duplicateNode, reactFlowInstance } = useContext(flowContext)
const [showInfoDialog, setShowInfoDialog] = useState(false)
const [infoDialogProps, setInfoDialogProps] = useState({})
const [cardDimensions, setCardDimensions] = useState({
width: '300px',
height: '250px'
})
// Add useEffect to update dimensions when reactFlowInstance becomes available
useEffect(() => {
if (reactFlowInstance) {
const node = reactFlowInstance.getNodes().find((node) => node.id === data.id)
if (node && node.width && node.height) {
setCardDimensions({
width: `${node.width}px`,
height: `${node.height}px`
})
}
}
}, [reactFlowInstance, data.id])
const defaultColor = '#666666' // fallback color if data.color is not present
const nodeColor = data.color || defaultColor
// Get different shades of the color based on state
const getStateColor = () => {
if (data.selected) return nodeColor
if (isHovered) return alpha(nodeColor, 0.8)
return alpha(nodeColor, 0.5)
}
const getOutputAnchors = () => {
return data.outputAnchors ?? []
}
const getAnchorPosition = (index) => {
const currentHeight = ref.current?.clientHeight || 0
const spacing = currentHeight / (getOutputAnchors().length + 1)
const position = spacing * (index + 1)
// Update node internals when we get a non-zero position
if (position > 0) {
updateNodeInternals(data.id)
}
return position
}
const getMinimumHeight = () => {
const outputCount = getOutputAnchors().length
// Use exactly 60px as minimum height
return Math.max(60, outputCount * 20 + 40)
}
const getBackgroundColor = () => {
if (customization.isDarkMode) {
return isHovered ? darken(nodeColor, 0.7) : darken(nodeColor, 0.8)
}
return isHovered ? lighten(nodeColor, 0.8) : lighten(nodeColor, 0.9)
}
const getStatusBackgroundColor = (status) => {
switch (status) {
case 'ERROR':
return theme.palette.error.dark
case 'INPROGRESS':
return theme.palette.warning.dark
case 'STOPPED':
case 'TERMINATED':
return theme.palette.error.main
case 'FINISHED':
return theme.palette.success.dark
default:
return theme.palette.primary.dark
}
}
const renderIcon = (node) => {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (!foundIcon) return null
return <foundIcon.icon size={24} color={'white'} />
}
useEffect(() => {
if (ref.current) {
setTimeout(() => {
setPosition(ref.current?.offsetTop + ref.current?.clientHeight / 2)
updateNodeInternals(data.id)
}, 10)
}
}, [data, ref, updateNodeInternals])
const onResizeEnd = useCallback(
(e, params) => {
if (!ref.current) return
// Set the card dimensions directly from resize params
setCardDimensions({
width: `${params.width}px`,
height: `${params.height}px`
})
},
[ref, setCardDimensions]
)
return (
<div ref={ref} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
<NodeToolbar align='start' isVisible={true}>
<Box style={{ display: 'flex', alignItems: 'center', flexDirection: 'row' }}>
{data.color && !data.icon ? (
<div
style={{
...theme.typography.commonAvatar,
...theme.typography.largeAvatar,
borderRadius: '15px',
backgroundColor: data.color,
cursor: 'grab',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: data.color
}}
>
{renderIcon(data)}
</div>
) : (
<div
style={{
...theme.typography.commonAvatar,
...theme.typography.largeAvatar,
borderRadius: '50%',
backgroundColor: 'white',
cursor: 'grab'
}}
>
<img
style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }}
src={`${baseURL}/api/v1/node-icon/${data.name}`}
alt={data.name}
/>
</div>
)}
<Typography
sx={{
fontSize: '0.85rem',
fontWeight: 500,
ml: 1
}}
>
{data.label}
</Typography>
</Box>
</NodeToolbar>
<StyledNodeToolbar align='end'>
<ButtonGroup sx={{ gap: 1 }} variant='outlined' aria-label='Basic button group'>
<IconButton
size={'small'}
title='Duplicate'
onClick={() => {
duplicateNode(data.id)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.primary.main
}
}}
>
<IconCopy size={20} />
</IconButton>
<IconButton
size={'small'}
title='Delete'
onClick={() => {
deleteNode(data.id)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.error.main
}
}}
>
<IconTrash size={20} />
</IconButton>
<IconButton
size={'small'}
title='Info'
onClick={() => {
setInfoDialogProps({ data })
setShowInfoDialog(true)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.info.main
}
}}
>
<IconInfoCircle size={20} />
</IconButton>
</ButtonGroup>
</StyledNodeToolbar>
<NodeResizer minWidth={300} minHeight={Math.max(getMinimumHeight(), 250)} onResizeEnd={onResizeEnd} />
<CardWrapper
content={false}
sx={{
borderColor: getStateColor(),
borderWidth: '1px',
boxShadow: data.selected ? `0 0 0 1px ${getStateColor()} !important` : 'none',
minHeight: Math.max(getMinimumHeight(), 250),
minWidth: 300,
width: cardDimensions.width,
height: cardDimensions.height,
backgroundColor: getBackgroundColor(),
display: 'flex',
'&:hover': {
boxShadow: data.selected ? `0 0 0 1px ${getStateColor()} !important` : 'none'
}
}}
border={false}
>
{data && data.status && (
<Tooltip title={data.status === 'ERROR' ? data.error || 'Error' : ''}>
<Avatar
variant='rounded'
sx={{
...theme.typography.smallAvatar,
borderRadius: '50%',
background:
data.status === 'STOPPED' || data.status === 'TERMINATED'
? 'white'
: getStatusBackgroundColor(data.status),
color: 'white',
ml: 2,
position: 'absolute',
top: -10,
right: -10
}}
>
{data.status === 'INPROGRESS' ? (
<IconLoader className='spin-animation' />
) : data.status === 'ERROR' ? (
<IconExclamationMark />
) : data.status === 'TERMINATED' ? (
<CancelIcon sx={{ color: getStatusBackgroundColor(data.status) }} />
) : data.status === 'STOPPED' ? (
<StopCircleIcon sx={{ color: getStatusBackgroundColor(data.status) }} />
) : (
<IconCheck />
)}
</Avatar>
</Tooltip>
)}
<Box sx={{ width: '100%' }}>
{!data.hideInput && (
<Handle
type='target'
position={Position.Left}
id={data.id}
style={{
width: 5,
height: 20,
backgroundColor: 'transparent',
border: 'none',
position: 'absolute',
left: -2
}}
>
<div
style={{
width: 5,
height: 20,
backgroundColor: nodeColor,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
</Handle>
)}
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Box
sx={{
height: `calc(${cardDimensions.height} - 20px)`,
width: `${cardDimensions.width}`,
overflow: 'hidden',
position: 'relative',
borderRadius: '10px'
}}
>
<div
ref={reactFlowWrapper}
style={{
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.palette.background.default
}}
>
<Background color='#aaa' gap={16} />
</div>
</Box>
</div>
{getOutputAnchors().map((outputAnchor, index) => {
return (
<Handle
type='source'
position={Position.Right}
key={outputAnchor.id}
id={outputAnchor.id}
style={{
height: 20,
width: 20,
top: getAnchorPosition(index),
backgroundColor: 'transparent',
border: 'none',
position: 'absolute',
right: -10,
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s'
}}
>
<div
style={{
position: 'absolute',
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: theme.palette.background.paper, // or 'white'
pointerEvents: 'none'
}}
/>
<IconCircleChevronRightFilled
size={20}
color={nodeColor}
style={{
pointerEvents: 'none',
position: 'relative',
zIndex: 1
}}
/>
</Handle>
)
})}
</Box>
</CardWrapper>
<NodeInfoDialog show={showInfoDialog} dialogProps={infoDialogProps} onCancel={() => setShowInfoDialog(false)}></NodeInfoDialog>
</div>
)
}
IterationNode.propTypes = {
data: PropTypes.object
}
export default memo(IterationNode)
@@ -0,0 +1,135 @@
import { useEffect, useState, useCallback, useRef, useContext } from 'react'
import ReactFlow, { Controls, Background, useNodesState, useEdgesState } from 'reactflow'
import 'reactflow/dist/style.css'
import '@/views/canvas/index.css'
import { useLocation, useNavigate } from 'react-router-dom'
// material-ui
import { Toolbar, Box, AppBar } from '@mui/material'
import { useTheme } from '@mui/material/styles'
// project imports
import AgentFlowNode from './AgentFlowNode'
import AgentFlowEdge from './AgentFlowEdge'
import IterationNode from './IterationNode'
import MarketplaceCanvasHeader from '@/views/marketplaces/MarketplaceCanvasHeader'
import StickyNote from './StickyNote'
import EditNodeDialog from '@/views/agentflowsv2/EditNodeDialog'
import { flowContext } from '@/store/context/ReactFlowContext'
const nodeTypes = { agentFlow: AgentFlowNode, stickyNote: StickyNote, iteration: IterationNode }
const edgeTypes = { agentFlow: AgentFlowEdge }
// ==============================|| CANVAS ||============================== //
const MarketplaceCanvasV2 = () => {
const theme = useTheme()
const navigate = useNavigate()
const { state } = useLocation()
const { flowData, name } = state
// ==============================|| ReactFlow ||============================== //
const [nodes, setNodes, onNodesChange] = useNodesState()
const [edges, setEdges, onEdgesChange] = useEdgesState()
const [editNodeDialogOpen, setEditNodeDialogOpen] = useState(false)
const [editNodeDialogProps, setEditNodeDialogProps] = useState({})
const reactFlowWrapper = useRef(null)
const { setReactFlowInstance } = useContext(flowContext)
// ==============================|| useEffect ||============================== //
useEffect(() => {
if (flowData) {
const initialFlow = JSON.parse(flowData)
setNodes(initialFlow.nodes || [])
setEdges(initialFlow.edges || [])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flowData])
const onChatflowCopy = (flowData) => {
const templateFlowData = JSON.stringify(flowData)
navigate('/v2/agentcanvas', { state: { templateFlowData } })
}
// eslint-disable-next-line
const onNodeDoubleClick = useCallback((event, node) => {
if (!node || !node.data) return
if (node.data.name === 'stickyNoteAgentflow') {
// dont show dialog
} else {
const dialogProps = {
data: node.data,
inputParams: node.data.inputParams.filter((inputParam) => !inputParam.hidden),
disabled: true
}
setEditNodeDialogProps(dialogProps)
setEditNodeDialogOpen(true)
}
})
return (
<>
<Box>
<AppBar
enableColorOnDark
position='fixed'
color='inherit'
elevation={1}
sx={{
bgcolor: theme.palette.background.default
}}
>
<Toolbar>
<MarketplaceCanvasHeader
flowName={name}
flowData={JSON.parse(flowData)}
onChatflowCopy={(flowData) => onChatflowCopy(flowData)}
/>
</Toolbar>
</AppBar>
<Box sx={{ pt: '70px', height: '100vh', width: '100%' }}>
<div className='reactflow-parent-wrapper'>
<div className='reactflow-wrapper' ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDoubleClick={onNodeDoubleClick}
onInit={setReactFlowInstance}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
minZoom={0.1}
>
<Controls
style={{
display: 'flex',
flexDirection: 'row',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
<Background color='#aaa' gap={16} />
<EditNodeDialog
show={editNodeDialogOpen}
dialogProps={editNodeDialogProps}
onCancel={() => setEditNodeDialogOpen(false)}
/>
</ReactFlow>
</div>
</div>
</Box>
</Box>
</>
)
}
export default MarketplaceCanvasV2
@@ -0,0 +1,136 @@
import PropTypes from 'prop-types'
import { useRef, useContext, useState } from 'react'
import { useSelector } from 'react-redux'
import { NodeToolbar } from 'reactflow'
// material-ui
import { styled, useTheme, alpha, darken, lighten } from '@mui/material/styles'
// project imports
import { ButtonGroup, IconButton, Box } from '@mui/material'
import { IconCopy, IconTrash } from '@tabler/icons-react'
import { Input } from '@/ui-component/input/Input'
import MainCard from '@/ui-component/cards/MainCard'
// const
import { flowContext } from '@/store/context/ReactFlowContext'
const CardWrapper = styled(MainCard)(({ theme }) => ({
background: theme.palette.card.main,
color: theme.darkTextPrimary,
border: 'solid 1px',
width: 'max-content',
height: 'auto',
padding: '10px',
boxShadow: 'none'
}))
const StyledNodeToolbar = styled(NodeToolbar)(({ theme }) => ({
backgroundColor: theme.palette.card.main,
color: theme.darkTextPrimary,
padding: '5px',
borderRadius: '10px',
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)'
}))
const StickyNote = ({ data }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const ref = useRef(null)
const { reactFlowInstance, deleteNode, duplicateNode } = useContext(flowContext)
const [inputParam] = data.inputParams
const [isHovered, setIsHovered] = useState(false)
const defaultColor = '#666666' // fallback color if data.color is not present
const nodeColor = data.color || defaultColor
// Get different shades of the color based on state
const getStateColor = () => {
if (data.selected) return nodeColor
if (isHovered) return alpha(nodeColor, 0.8)
return alpha(nodeColor, 0.5)
}
const getBackgroundColor = () => {
if (customization.isDarkMode) {
return isHovered ? darken(nodeColor, 0.7) : darken(nodeColor, 0.8)
}
return isHovered ? lighten(nodeColor, 0.8) : lighten(nodeColor, 0.9)
}
return (
<div ref={ref} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
<StyledNodeToolbar>
<ButtonGroup sx={{ gap: 1 }} variant='outlined' aria-label='Basic button group'>
<IconButton
size={'small'}
title='Duplicate'
onClick={() => {
duplicateNode(data.id)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.primary.main
}
}}
>
<IconCopy size={20} />
</IconButton>
<IconButton
size={'small'}
title='Delete'
onClick={() => {
deleteNode(data.id)
}}
sx={{
color: customization.isDarkMode ? 'white' : 'inherit',
'&:hover': {
color: theme.palette.error.main
}
}}
>
<IconTrash size={20} />
</IconButton>
</ButtonGroup>
</StyledNodeToolbar>
<CardWrapper
content={false}
sx={{
borderColor: getStateColor(),
borderWidth: '1px',
boxShadow: data.selected ? `0 0 0 1px ${getStateColor()} !important` : 'none',
minHeight: 60,
height: 'auto',
backgroundColor: getBackgroundColor(),
display: 'flex',
alignItems: 'center',
'&:hover': {
boxShadow: data.selected ? `0 0 0 1px ${getStateColor()} !important` : 'none'
}
}}
border={false}
>
<Box>
<Input
key={data.id}
placeholder={inputParam.placeholder}
inputParam={inputParam}
onChange={(newValue) => (data.inputs[inputParam.name] = newValue)}
value={data.inputs[inputParam.name] ?? inputParam.default ?? ''}
nodes={reactFlowInstance ? reactFlowInstance.getNodes() : []}
edges={reactFlowInstance ? reactFlowInstance.getEdges() : []}
nodeId={data.id}
/>
</Box>
</CardWrapper>
</div>
)
}
StickyNote.propTypes = {
data: PropTypes.object
}
export default StickyNote
@@ -0,0 +1,56 @@
.edgebutton {
width: 20px;
height: 20px;
background: #eee;
border: 1px solid #fff;
cursor: pointer;
border-radius: 50%;
font-size: 12px;
line-height: 1;
}
.edgebutton:hover {
background: #5e35b1;
color: #eee;
box-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.08);
}
.edgebutton-foreignobject div {
background: transparent;
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
min-height: 40px;
}
.reactflow-parent-wrapper {
display: flex;
flex-grow: 1;
height: 100%;
}
.reactflow-parent-wrapper .reactflow-wrapper {
flex-grow: 1;
height: 100%;
}
.chatflow-canvas .react-flow__handle-connecting {
cursor: not-allowed;
background: #db4e4e !important;
}
.chatflow-canvas .react-flow__handle-valid {
cursor: crosshair;
background: #5dba62 !important;
}
.agent-flow-edge-selector:hover {
cursor: pointer;
}
.agent-flow-edge-selector:hover + .agent-flow-edge {
stroke-width: 3 !important;
opacity: 1;
}
+7 -1
View File
@@ -367,7 +367,13 @@ const APIKey = () => {
<ErrorBoundary error={error} />
) : (
<Stack flexDirection='column' sx={{ gap: 3 }}>
<ViewHeader onSearchChange={onSearchChange} search={true} searchPlaceholder='Search API Keys' title='API Keys'>
<ViewHeader
onSearchChange={onSearchChange}
search={true}
searchPlaceholder='Search API Keys'
title='API Keys'
description='Flowise API & SDK authentication keys'
>
<Button
variant='outlined'
sx={{ borderRadius: 2, height: '100%' }}
@@ -72,9 +72,10 @@ const AddCustomAssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) =>
onConfirm(createResp.data.id)
}
} catch (err) {
const errorData = typeof err === 'string' ? err : err.response?.data || `${err.response.data.message}`
enqueueSnackbar({
message: `Failed to add new Custom Assistant: ${errorData}`,
message: `Failed to add new Custom Assistant: ${
typeof err.response.data === 'object' ? err.response.data.message : err.response.data
}`,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
@@ -1000,7 +1000,7 @@ const CustomAssistantConfigurePreview = () => {
sx={{ borderRadius: 20 }}
size='small'
variant='text'
onClick={() => generateInstruction(customAssistantInstruction)}
onClick={() => generateInstruction()}
startIcon={<IconWand size={20} />}
>
Generate
@@ -98,6 +98,7 @@ const CustomAssistantLayout = () => {
search={true}
searchPlaceholder='Search Assistants'
title='Custom Assistant'
description='Create custom assistants with your choice of LLMs'
onBack={() => navigate(-1)}
>
<StyledButton
+4 -1
View File
@@ -122,7 +122,10 @@ const Assistants = () => {
<>
<MainCard>
<Stack flexDirection='column' sx={{ gap: 3 }}>
<ViewHeader title='Assistants' />
<ViewHeader
title='Assistants'
description='Chat assistants with instructions, tools, and files to respond to user queries'
/>
<FeatureCards />
</Stack>
</MainCard>
@@ -120,6 +120,7 @@ const OpenAIAssistantLayout = () => {
search={true}
searchPlaceholder='Search Assistants'
title='OpenAI Assistant'
description='Create assistants using OpenAI Assistant API'
onBack={() => navigate(-1)}
>
<Button
+113 -26
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, memo } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import PropTypes from 'prop-types'
@@ -35,15 +35,16 @@ import PerfectScrollbar from 'react-perfect-scrollbar'
import MainCard from '@/ui-component/cards/MainCard'
import Transitions from '@/ui-component/extended/Transitions'
import { StyledFab } from '@/ui-component/button/StyledFab'
import AgentflowGeneratorDialog from '@/ui-component/dialog/AgentflowGeneratorDialog'
// icons
import { IconPlus, IconSearch, IconMinus, IconX } from '@tabler/icons-react'
import { IconPlus, IconSearch, IconMinus, IconX, IconSparkles } from '@tabler/icons-react'
import LlamaindexPNG from '@/assets/images/llamaindex.png'
import LangChainPNG from '@/assets/images/langchain.png'
import utilNodesPNG from '@/assets/images/utilNodes.png'
// const
import { baseURL } from '@/store/constant'
import { baseURL, AGENTFLOW_ICONS } from '@/store/constant'
import { SET_COMPONENT_NODES } from '@/store/actions'
// ==============================|| ADD NODES||============================== //
@@ -69,7 +70,7 @@ const blacklistForChatflowCanvas = {
Memory: agentMemoryNodes
}
const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
const AddNodes = ({ nodesData, node, isAgentCanvas, isAgentflowv2, onFlowGenerated }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const dispatch = useDispatch()
@@ -80,6 +81,11 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
const [categoryExpanded, setCategoryExpanded] = useState({})
const [tabValue, setTabValue] = useState(0)
const [openDialog, setOpenDialog] = useState(false)
const [dialogProps, setDialogProps] = useState({})
const isAgentCanvasV2 = window.location.pathname.includes('/v2/agentcanvas')
const anchorRef = useRef(null)
const prevOpen = useRef(open)
const ps = useRef()
@@ -177,6 +183,15 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
const filteredResult = {}
for (const category in result) {
if (isAgentCanvasV2) {
if (category !== 'Agent Flows') {
continue
}
} else {
if (category === 'Agent Flows') {
continue
}
}
// Filter out blacklisted categories
if (!blacklistCategoriesForAgentCanvas.includes(category)) {
// Filter out LlamaIndex nodes
@@ -195,6 +210,7 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
accordianCategories['Multi Agents'] = true
accordianCategories['Sequential Agents'] = true
accordianCategories['Memory'] = true
accordianCategories['Agent Flows'] = true
setCategoryExpanded(accordianCategories)
} else {
const taggedNodes = groupByTags(nodes, newTabValue)
@@ -208,7 +224,7 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
const filteredResult = {}
for (const category in result) {
if (category === 'Multi Agents' || category === 'Sequential Agents') {
if (category === 'Agent Flows' || category === 'Multi Agents' || category === 'Sequential Agents') {
continue
}
if (Object.keys(blacklistForChatflowCanvas).includes(category)) {
@@ -255,6 +271,13 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
}
}
const renderIcon = (node) => {
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.name)
if (!foundIcon) return null
return <foundIcon.icon size={30} color={node.color} />
}
useEffect(() => {
if (prevOpen.current === true && open === false) {
anchorRef.current.focus()
@@ -276,6 +299,25 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodesData, dispatch])
// Handle dialog open/close
const handleOpenDialog = () => {
setOpenDialog(true)
setDialogProps({
title: 'What would you like to build?',
description:
'Enter your prompt to generate an agentflow. Performance may vary with different models. Only nodes and edges are generated, you will need to fill in the input fields for each node.'
})
}
const handleCloseDialog = () => {
setOpenDialog(false)
}
const handleConfirmDialog = () => {
setOpenDialog(false)
onFlowGenerated()
}
return (
<>
<StyledFab
@@ -289,6 +331,33 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
>
{open ? <IconMinus /> : <IconPlus />}
</StyledFab>
{isAgentflowv2 && (
<StyledFab
sx={{
left: 40,
top: 20,
background: 'linear-gradient(45deg, #FF6B6B 30%, #FF8E53 90%)',
'&:hover': {
background: 'linear-gradient(45deg, #FF8E53 30%, #FF6B6B 90%)'
}
}}
onClick={handleOpenDialog}
size='small'
color='primary'
aria-label='generate'
title='Generate Agentflow'
>
<IconSparkles />
</StyledFab>
)}
<AgentflowGeneratorDialog
show={openDialog}
dialogProps={dialogProps}
onCancel={handleCloseDialog}
onConfirm={handleConfirmDialog}
/>
<Popper
placement='bottom-end'
open={open}
@@ -489,27 +558,43 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
}}
>
<ListItem alignItems='center'>
<ListItemAvatar>
<div
style={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: 'white'
}}
>
<img
{node.color && !node.icon ? (
<ListItemAvatar>
<div
style={{
width: '100%',
height: '100%',
padding: 10,
objectFit: 'contain'
width: 50,
height: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
alt={node.name}
src={`${baseURL}/api/v1/node-icon/${node.name}`}
/>
</div>
</ListItemAvatar>
>
{renderIcon(node)}
</div>
</ListItemAvatar>
) : (
<ListItemAvatar>
<div
style={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: 'white'
}}
>
<img
style={{
width: '100%',
height: '100%',
padding: 10,
objectFit: 'contain'
}}
alt={node.name}
src={`${baseURL}/api/v1/node-icon/${node.name}`}
/>
</div>
</ListItemAvatar>
)}
<ListItemText
sx={{ ml: 1 }}
primary={
@@ -583,7 +668,9 @@ const AddNodes = ({ nodesData, node, isAgentCanvas }) => {
AddNodes.propTypes = {
nodesData: PropTypes.array,
node: PropTypes.object,
isAgentCanvas: PropTypes.bool
onFlowGenerated: PropTypes.func,
isAgentCanvas: PropTypes.bool,
isAgentflowv2: PropTypes.bool
}
export default AddNodes
export default memo(AddNodes)
+2 -2
View File
@@ -1,7 +1,7 @@
import { getBezierPath, EdgeText } from 'reactflow'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
import { useContext } from 'react'
import { useContext, memo } from 'react'
import { SET_DIRTY } from '@/store/actions'
import { flowContext } from '@/store/context/ReactFlowContext'
import { IconX } from '@tabler/icons-react'
@@ -75,4 +75,4 @@ ButtonEdge.propTypes = {
markerEnd: PropTypes.any
}
export default ButtonEdge
export default memo(ButtonEdge)
+17 -6
View File
@@ -33,7 +33,7 @@ import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackba
// ==============================|| CANVAS HEADER ||============================== //
const CanvasHeader = ({ chatflow, isAgentCanvas, handleSaveFlow, handleDeleteFlow, handleLoadFlow }) => {
const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, handleDeleteFlow, handleLoadFlow }) => {
const theme = useTheme()
const dispatch = useDispatch()
const navigate = useNavigate()
@@ -122,7 +122,13 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, handleSaveFlow, handleDeleteFlo
const parsedFlowData = JSON.parse(flowData)
flowData = JSON.stringify(parsedFlowData)
localStorage.setItem('duplicatedFlowData', flowData)
window.open(`${uiBaseURL}/${isAgentCanvas ? 'agentcanvas' : 'canvas'}`, '_blank')
if (isAgentflowV2) {
window.open(`${uiBaseURL}/v2/agentcanvas`, '_blank')
} else if (isAgentCanvas) {
window.open(`${uiBaseURL}/agentcanvas`, '_blank')
} else {
window.open(`${uiBaseURL}/canvas`, '_blank')
}
} catch (e) {
console.error(e)
}
@@ -255,9 +261,13 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, handleSaveFlow, handleDeleteFlo
}
}}
color='inherit'
onClick={() =>
window.history.state && window.history.state.idx > 0 ? navigate(-1) : navigate('/', { replace: true })
}
onClick={() => {
if (window.history.state && window.history.state.idx > 0) {
navigate(-1)
} else {
navigate('/', { replace: true })
}
}}
>
<IconChevronLeft stroke={1.5} size='1.3rem' />
</Avatar>
@@ -486,7 +496,8 @@ CanvasHeader.propTypes = {
handleSaveFlow: PropTypes.func,
handleDeleteFlow: PropTypes.func,
handleLoadFlow: PropTypes.func,
isAgentCanvas: PropTypes.bool
isAgentCanvas: PropTypes.bool,
isAgentflowV2: PropTypes.bool
}
export default CanvasHeader
+14 -5
View File
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types'
import { useContext, useState, useEffect } from 'react'
import { useContext, useState, useEffect, memo } from 'react'
import { useSelector } from 'react-redux'
// material-ui
@@ -64,6 +64,12 @@ const CanvasNode = ({ data }) => {
setShowDialog(true)
}
const getBorderColor = () => {
if (data.selected) return theme.palette.primary.main
else if (theme?.customization?.isDarkMode) return theme.palette.grey[900] + 25
else return theme.palette.grey[900] + 50
}
useEffect(() => {
const componentNode = canvas.componentNodes.find((nd) => nd.name === data.name)
if (componentNode) {
@@ -88,7 +94,7 @@ const CanvasNode = ({ data }) => {
content={false}
sx={{
padding: 0,
borderColor: data.selected ? theme.palette.primary.main : theme.palette.text.secondary
borderColor: getBorderColor()
}}
border={false}
>
@@ -142,14 +148,16 @@ const CanvasNode = ({ data }) => {
>
<Box>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Box style={{ width: 50, marginRight: 10, padding: 5 }}>
<Box style={{ width: 50, marginRight: 10, padding: 10 }}>
<div
style={{
...theme.typography.commonAvatar,
...theme.typography.largeAvatar,
borderRadius: '50%',
backgroundColor: 'white',
cursor: 'grab'
cursor: 'grab',
width: '40px',
height: '40px'
}}
>
<img
@@ -218,6 +226,7 @@ const CanvasNode = ({ data }) => {
))}
{data.inputParams
.filter((inputParam) => !inputParam.hidden)
.filter((inputParam) => inputParam.display !== false)
.map((inputParam, index) => (
<NodeInputHandler
key={index}
@@ -283,4 +292,4 @@ CanvasNode.propTypes = {
data: PropTypes.object
}
export default CanvasNode
export default memo(CanvasNode)
@@ -12,12 +12,13 @@ import CredentialListDialog from '@/views/credentials/CredentialListDialog'
// API
import credentialsApi from '@/api/credentials'
import { FLOWISE_CREDENTIAL_ID } from '@/store/constant'
// ===========================|| CredentialInputHandler ||=========================== //
const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false }) => {
const ref = useRef(null)
const [credentialId, setCredentialId] = useState(data?.credential ?? '')
const [credentialId, setCredentialId] = useState(data?.credential || (data?.inputs && data.inputs[FLOWISE_CREDENTIAL_ID]) || '')
const [showCredentialListDialog, setShowCredentialListDialog] = useState(false)
const [credentialListDialogProps, setCredentialListDialogProps] = useState({})
const [showSpecificCredentialDialog, setShowSpecificCredentialDialog] = useState(false)
@@ -89,7 +90,7 @@ const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false }
}
useEffect(() => {
setCredentialId(data?.credential ?? '')
setCredentialId(data?.credential || (data?.inputs && data.inputs[FLOWISE_CREDENTIAL_ID]) || '')
}, [data])
return (
+561 -75
View File
@@ -1,49 +1,82 @@
import PropTypes from 'prop-types'
import { useContext, useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { Handle, Position, useUpdateNodeInternals } from 'reactflow'
import { useEffect, useRef, useState, useContext } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { cloneDeep } from 'lodash'
// material-ui
import { Tabs } from '@mui/base/Tabs'
import IconAutoFixHigh from '@mui/icons-material/AutoFixHigh'
import { Box, Button, IconButton, Popper, TextField, Tooltip, Typography } from '@mui/material'
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
import { styled, useTheme } from '@mui/material/styles'
import { tooltipClasses } from '@mui/material/Tooltip'
import { useTheme, styled } from '@mui/material/styles'
import {
Popper,
Box,
Typography,
Tooltip,
IconButton,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions
} from '@mui/material'
import { useGridApiContext } from '@mui/x-data-grid'
import { IconAlertTriangle, IconArrowsMaximize, IconBulb, IconEdit, IconRefresh } from '@tabler/icons-react'
import IconAutoFixHigh from '@mui/icons-material/AutoFixHigh'
import { tooltipClasses } from '@mui/material/Tooltip'
import { IconWand, IconVariable, IconArrowsMaximize, IconEdit, IconAlertTriangle, IconBulb, IconRefresh, IconX } from '@tabler/icons-react'
import { Tabs } from '@mui/base/Tabs'
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
// project import
import { flowContext } from '@/store/context/ReactFlowContext'
import ConditionDialog from '@/ui-component/dialog/ConditionDialog'
import ExpandTextDialog from '@/ui-component/dialog/ExpandTextDialog'
import FormatPromptValuesDialog from '@/ui-component/dialog/FormatPromptValuesDialog'
import InputHintDialog from '@/ui-component/dialog/InputHintDialog'
import ManageScrapedLinksDialog from '@/ui-component/dialog/ManageScrapedLinksDialog'
import NvidiaNIMDialog from '@/ui-component/dialog/NvidiaNIMDialog'
import PromptLangsmithHubDialog from '@/ui-component/dialog/PromptLangsmithHubDialog'
import { AsyncDropdown } from '@/ui-component/dropdown/AsyncDropdown'
import { Dropdown } from '@/ui-component/dropdown/Dropdown'
import { MultiDropdown } from '@/ui-component/dropdown/MultiDropdown'
import { CodeEditor } from '@/ui-component/editor/CodeEditor'
import { File } from '@/ui-component/file/File'
import { DataGrid } from '@/ui-component/grid/DataGrid'
import { AsyncDropdown } from '@/ui-component/dropdown/AsyncDropdown'
import { Input } from '@/ui-component/input/Input'
import { JsonEditorInput } from '@/ui-component/json/JsonEditor'
import { RichInput } from '@/ui-component/input/RichInput'
import { DataGrid } from '@/ui-component/grid/DataGrid'
import { File } from '@/ui-component/file/File'
import { SwitchInput } from '@/ui-component/switch/Switch'
import { Tab } from '@/ui-component/tabs/Tab'
import { flowContext } from '@/store/context/ReactFlowContext'
import { JsonEditorInput } from '@/ui-component/json/JsonEditor'
import { TooltipWithParser } from '@/ui-component/tooltip/TooltipWithParser'
import { CodeEditor } from '@/ui-component/editor/CodeEditor'
import { TabPanel } from '@/ui-component/tabs/TabPanel'
import { TabsList } from '@/ui-component/tabs/TabsList'
import { TooltipWithParser } from '@/ui-component/tooltip/TooltipWithParser'
import AssistantDialog from '@/views/assistants/openai/AssistantDialog'
import { ArrayRenderer } from '@/ui-component/array/ArrayRenderer'
import { Tab } from '@/ui-component/tabs/Tab'
import { ConfigInput } from '@/views/agentflowsv2/ConfigInput'
import { BackdropLoader } from '@/ui-component/loading/BackdropLoader'
import DocStoreInputHandler from '@/views/docstore/DocStoreInputHandler'
import ToolDialog from '@/views/tools/ToolDialog'
import AssistantDialog from '@/views/assistants/openai/AssistantDialog'
import FormatPromptValuesDialog from '@/ui-component/dialog/FormatPromptValuesDialog'
import ExpandTextDialog from '@/ui-component/dialog/ExpandTextDialog'
import ExpandRichInputDialog from '@/ui-component/dialog/ExpandRichInputDialog'
import ConditionDialog from '@/ui-component/dialog/ConditionDialog'
import PromptLangsmithHubDialog from '@/ui-component/dialog/PromptLangsmithHubDialog'
import ManageScrapedLinksDialog from '@/ui-component/dialog/ManageScrapedLinksDialog'
import CredentialInputHandler from './CredentialInputHandler'
import InputHintDialog from '@/ui-component/dialog/InputHintDialog'
import NvidiaNIMDialog from '@/ui-component/dialog/NvidiaNIMDialog'
import PromptGeneratorDialog from '@/ui-component/dialog/PromptGeneratorDialog'
// API
import assistantsApi from '@/api/assistants'
import documentstoreApi from '@/api/documentstore'
// utils
import { getAvailableNodesForVariable, getCustomConditionOutputs, getInputVariables, isValidConnection } from '@/utils/genericHelper'
import {
initNode,
getInputVariables,
getCustomConditionOutputs,
isValidConnection,
getAvailableNodesForVariable
} from '@/utils/genericHelper'
import useNotifier from '@/utils/useNotifier'
// const
import { FLOWISE_CREDENTIAL_ID } from '@/store/constant'
import { baseURL, FLOWISE_CREDENTIAL_ID } from '@/store/constant'
import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
const EDITABLE_OPTIONS = ['selectedTool', 'selectedAssistant']
@@ -74,16 +107,27 @@ const NodeInputHandler = ({
disabled = false,
isAdditionalParams = false,
disablePadding = false,
onHideNodeInfoDialog
parentParamForArray = null,
arrayIndex = null,
onHideNodeInfoDialog,
onCustomDataChange
}) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const ref = useRef(null)
const { reactFlowInstance, deleteEdge } = useContext(flowContext)
const { reactFlowInstance, deleteEdge, onNodeDataChange } = useContext(flowContext)
const updateNodeInternals = useUpdateNodeInternals()
useNotifier()
const dispatch = useDispatch()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
const [position, setPosition] = useState(0)
const [showExpandDialog, setShowExpandDialog] = useState(false)
const [expandDialogProps, setExpandDialogProps] = useState({})
const [showExpandRichDialog, setShowExpandRichDialog] = useState(false)
const [expandRichDialogProps, setExpandRichDialogProps] = useState({})
const [showAsyncOptionDialog, setAsyncOptionEditDialog] = useState('')
const [asyncOptionEditDialogProps, setAsyncOptionEditDialogProps] = useState({})
const [reloadTimestamp, setReloadTimestamp] = useState(Date.now().toString())
@@ -99,6 +143,28 @@ const NodeInputHandler = ({
const [isNvidiaNIMDialogOpen, setIsNvidiaNIMDialogOpen] = useState(false)
const [tabValue, setTabValue] = useState(0)
const [modelSelectionDialogOpen, setModelSelectionDialogOpen] = useState(false)
const [availableChatModels, setAvailableChatModels] = useState([])
const [availableChatModelsOptions, setAvailableChatModelsOptions] = useState([])
const [selectedTempChatModel, setSelectedTempChatModel] = useState({})
const [modelSelectionCallback, setModelSelectionCallback] = useState(null)
const [loading, setLoading] = useState(false)
const [promptGeneratorDialogOpen, setPromptGeneratorDialogOpen] = useState(false)
const [promptGeneratorDialogProps, setPromptGeneratorDialogProps] = useState({})
const handleDataChange = ({ inputParam, newValue }) => {
data.inputs[inputParam.name] = newValue
const allowedShowHideInputTypes = ['boolean', 'asyncOptions', 'asyncMultiOptions', 'options', 'multiOptions']
if (allowedShowHideInputTypes.includes(inputParam.type)) {
if (onCustomDataChange) {
onCustomDataChange({ nodeId: data.id, inputParam, newValue })
} else {
onNodeDataChange({ nodeId: data.id, inputParam, newValue })
}
}
}
const onInputHintDialogClicked = (hint) => {
const dialogProps = {
...hint
@@ -113,11 +179,19 @@ const NodeInputHandler = ({
inputParam,
disabled,
languageType,
nodes: reactFlowInstance?.getNodes() || [],
edges: reactFlowInstance?.getEdges() || [],
nodeId: data.id,
confirmButtonName: 'Save',
cancelButtonName: 'Cancel'
}
setExpandDialogProps(dialogProps)
setShowExpandDialog(true)
if (inputParam.acceptVariable) {
setExpandRichDialogProps(dialogProps)
setShowExpandRichDialog(true)
} else {
setExpandDialogProps(dialogProps)
setShowExpandDialog(true)
}
}
const onConditionDialogClicked = (inputParam) => {
@@ -375,6 +449,11 @@ const NodeInputHandler = ({
setShowExpandDialog(false)
}
const onExpandRichDialogSave = (newValue, inputParamName) => {
data.inputs[inputParamName] = newValue
setShowExpandRichDialog(false)
}
const onConditionDialogSave = (newData, inputParam, tabValue) => {
data.inputs[`${inputParam.tabIdentifier}_${data.id}`] = inputParam.tabs[tabValue].name
@@ -437,8 +516,10 @@ const NodeInputHandler = ({
const onConfirmAsyncOption = (selectedOptionId = '') => {
if (!selectedOptionId) {
data.inputs[showAsyncOptionDialog] = ''
handleDataChange({ inputParam: { name: showAsyncOptionDialog }, newValue: '' })
} else {
data.inputs[showAsyncOptionDialog] = selectedOptionId
handleDataChange({ inputParam: { name: showAsyncOptionDialog }, newValue: selectedOptionId })
setReloadTimestamp(Date.now().toString())
}
setAsyncOptionEditDialogProps({})
@@ -452,6 +533,230 @@ const NodeInputHandler = ({
}
}
const loadChatModels = async () => {
try {
const resp = await assistantsApi.getChatModels()
if (resp.data) {
const chatModels = resp.data ?? []
const chatModelsOptions = chatModels.map((model) => ({
name: model.name,
label: model.label,
description: model.description,
imageSrc: `${baseURL}/api/v1/node-icon/${model.name}`
}))
setAvailableChatModels(chatModels)
setAvailableChatModelsOptions(chatModelsOptions)
}
} catch (error) {
console.error('Error loading chat models:', error)
}
}
const checkInputParamsMandatory = () => {
let canSubmit = true
if (selectedTempChatModel && Object.keys(selectedTempChatModel).length > 0) {
const inputParams = (selectedTempChatModel.inputParams ?? []).filter((inputParam) => !inputParam.hidden)
for (const inputParam of inputParams) {
if (!inputParam.optional && (!selectedTempChatModel.inputs[inputParam.name] || !selectedTempChatModel.credential)) {
if (inputParam.type === 'credential' && !selectedTempChatModel.credential) {
canSubmit = false
break
} else if (inputParam.type !== 'credential' && !selectedTempChatModel.inputs[inputParam.name]) {
canSubmit = false
break
}
}
}
}
return canSubmit
}
const displayWarning = () => {
enqueueSnackbar({
message: 'Please fill in all mandatory fields.',
options: {
key: new Date().getTime() + Math.random(),
variant: 'warning',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
const generateDocStoreToolDesc = async (storeId) => {
if (!storeId) {
enqueueSnackbar({
message: 'Please select a knowledge base',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
return
}
storeId = storeId.split(':')[0]
const isValid = checkInputParamsMandatory()
if (!isValid) {
displayWarning()
return
}
// Check if model is already selected in the node
const currentNode = reactFlowInstance?.getNodes().find((node) => node.id === data.id)
const currentNodeInputs = currentNode?.data?.inputs
const existingModel = currentNodeInputs?.llmModel || currentNodeInputs?.agentModel || currentNodeInputs?.humanInputModel
if (existingModel) {
try {
setLoading(true)
const selectedChatModelObj = {
name: existingModel,
inputs:
currentNodeInputs?.llmModelConfig || currentNodeInputs?.agentModelConfig || currentNodeInputs?.humanInputModelConfig
}
const resp = await documentstoreApi.generateDocStoreToolDesc(storeId, { selectedChatModel: selectedChatModelObj })
if (resp.data) {
setLoading(false)
const content = resp.data?.content || resp.data.kwargs?.content
// Update the input value directly
data.inputs[inputParam.name] = content
enqueueSnackbar({
message: 'Document Store Tool Description generated successfully',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
} catch (error) {
console.error('Error generating doc store tool desc', error)
setLoading(false)
enqueueSnackbar({
message: typeof error.response.data === 'object' ? error.response.data.message : error.response.data,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
return
}
// If no model selected, load chat models and open model selection dialog
await loadChatModels()
setModelSelectionCallback(() => async (selectedModel) => {
try {
setLoading(true)
const selectedChatModelObj = {
name: selectedModel.name,
inputs: selectedModel.inputs
}
const resp = await documentstoreApi.generateDocStoreToolDesc(storeId, { selectedChatModel: selectedChatModelObj })
if (resp.data) {
setLoading(false)
const content = resp.data?.content || resp.data.kwargs?.content
// Update the input value directly
data.inputs[inputParam.name] = content
enqueueSnackbar({
message: 'Document Store Tool Description generated successfully',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
} catch (error) {
console.error('Error generating doc store tool desc', error)
setLoading(false)
enqueueSnackbar({
message: typeof error.response.data === 'object' ? error.response.data.message : error.response.data,
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
})
setModelSelectionDialogOpen(true)
}
const generateInstruction = async () => {
const isValid = checkInputParamsMandatory()
if (!isValid) {
displayWarning()
return
}
const currentNode = reactFlowInstance?.getNodes().find((node) => node.id === data.id)
const currentNodeInputs = currentNode?.data?.inputs
// Check if model is already selected in the node
const existingModel = currentNodeInputs?.llmModel || currentNodeInputs?.agentModel || currentNodeInputs?.humanInputModel
if (existingModel) {
// Open prompt generator dialog directly with existing model
setPromptGeneratorDialogProps({
title: 'Generate Instructions',
description: 'You can generate a prompt template by sharing basic details about your task.',
data: {
selectedChatModel: {
name: existingModel,
inputs:
currentNodeInputs?.llmModelConfig ||
currentNodeInputs?.agentModelConfig ||
currentNodeInputs?.humanInputModelConfig
}
}
})
setPromptGeneratorDialogOpen(true)
return
}
// If no model selected, load chat models and open model selection dialog
await loadChatModels()
setModelSelectionCallback(() => async (selectedModel) => {
// After model selection, open prompt generator dialog
setPromptGeneratorDialogProps({
title: 'Generate Instructions',
description: 'You can generate a prompt template by sharing basic details about your task.',
data: { selectedChatModel: selectedModel }
})
setPromptGeneratorDialogOpen(true)
})
setModelSelectionDialogOpen(true)
}
useEffect(() => {
if (ref.current && ref.current.offsetTop && ref.current.clientHeight) {
setPosition(ref.current.offsetTop + ref.current.clientHeight / 2)
@@ -537,7 +842,7 @@ const NodeInputHandler = ({
></PromptLangsmithHubDialog>
</>
)}
{data.name === 'Chat NVIDIA NIM' && inputParam.name === 'modelName' && (
{data.name === 'chatNvidiaNIM' && inputParam.name === 'modelName' && (
<>
<Button
style={{
@@ -553,7 +858,7 @@ const NodeInputHandler = ({
</Button>
</>
)}
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>
{inputParam.label}
{!inputParam.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
@@ -587,12 +892,47 @@ const NodeInputHandler = ({
{inputParam.hint.label}
</Button>
)}
{inputParam.acceptVariable && inputParam.type === 'string' && (
<Tooltip title='Type {{ to select variables'>
<IconVariable size={20} style={{ color: 'teal' }} />
</Tooltip>
)}
{inputParam.generateDocStoreDescription && (
<IconButton
title='Generate knowledge base description'
sx={{
height: 25,
width: 25
}}
size='small'
color='secondary'
onClick={() => generateDocStoreToolDesc(data.inputs['documentStore'])}
>
<IconWand />
</IconButton>
)}
{inputParam.generateInstruction && (
<IconButton
title='Generate instructions'
sx={{
height: 25,
width: 25,
ml: 0.5
}}
size='small'
color='secondary'
onClick={() => generateInstruction()}
>
<IconWand />
</IconButton>
)}
{((inputParam.type === 'string' && inputParam.rows) || inputParam.type === 'code') && (
<IconButton
size='small'
sx={{
height: 25,
width: 25
width: 25,
ml: 0.5
}}
title='Expand'
color='primary'
@@ -650,17 +990,19 @@ const NodeInputHandler = ({
))}
</TabsList>
</Tabs>
{inputParam.tabs.map((inputChildParam, index) => (
<TabPanel key={index} value={getTabValue(inputParam)} index={index}>
<NodeInputHandler
disabled={inputChildParam.disabled}
inputParam={inputChildParam}
data={data}
isAdditionalParams={true}
disablePadding={true}
/>
</TabPanel>
))}
{inputParam.tabs
.filter((inputParam) => inputParam.display !== false)
.map((inputChildParam, index) => (
<TabPanel key={index} value={getTabValue(inputParam)} index={index}>
<NodeInputHandler
disabled={inputChildParam.disabled}
inputParam={inputChildParam}
data={data}
isAdditionalParams={true}
disablePadding={true}
/>
</TabPanel>
))}
</>
)}
{inputParam.type === 'file' && (
@@ -674,7 +1016,7 @@ const NodeInputHandler = ({
{inputParam.type === 'boolean' && (
<SwitchInput
disabled={disabled}
onChange={(newValue) => (data.inputs[inputParam.name] = newValue)}
onChange={(newValue) => handleDataChange({ inputParam, newValue })}
value={data.inputs[inputParam.name] ?? inputParam.default ?? false}
/>
)}
@@ -723,18 +1065,33 @@ const NodeInputHandler = ({
</div>
</>
)}
{(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') && (
<Input
key={data.inputs[inputParam.name]}
disabled={disabled}
inputParam={inputParam}
onChange={(newValue) => (data.inputs[inputParam.name] = newValue)}
value={data.inputs[inputParam.name] ?? inputParam.default ?? ''}
nodes={inputParam?.acceptVariable && reactFlowInstance ? reactFlowInstance.getNodes() : []}
edges={inputParam?.acceptVariable && reactFlowInstance ? reactFlowInstance.getEdges() : []}
nodeId={data.id}
/>
)}
{(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') &&
(inputParam?.acceptVariable ? (
<RichInput
key={data.inputs[inputParam.name]}
placeholder={inputParam.placeholder}
disabled={disabled}
inputParam={inputParam}
onChange={(newValue) => (data.inputs[inputParam.name] = newValue)}
value={data.inputs[inputParam.name] ?? inputParam.default ?? ''}
nodes={reactFlowInstance ? reactFlowInstance.getNodes() : []}
edges={reactFlowInstance ? reactFlowInstance.getEdges() : []}
nodeId={data.id}
/>
) : (
<Input
key={data.inputs[inputParam.name]}
placeholder={inputParam.placeholder}
disabled={disabled}
inputParam={inputParam}
onChange={(newValue) => (data.inputs[inputParam.name] = newValue)}
value={data.inputs[inputParam.name] ?? inputParam.default ?? ''}
nodes={[]}
edges={[]}
nodeId={data.id}
/>
))}
{inputParam.type === 'json' && (
<>
{!inputParam?.acceptVariable && (
@@ -777,28 +1134,35 @@ const NodeInputHandler = ({
</>
)}
{inputParam.type === 'options' && (
<Dropdown
disabled={disabled}
name={inputParam.name}
options={getDropdownOptions(inputParam)}
freeSolo={inputParam.freeSolo}
onSelect={(newValue) => (data.inputs[inputParam.name] = newValue)}
value={data.inputs[inputParam.name] ?? inputParam.default ?? 'choose an option'}
/>
<div key={`${data.id}_${JSON.stringify(data.inputs[inputParam.name])}`}>
<Dropdown
disabled={disabled}
name={inputParam.name}
options={getDropdownOptions(inputParam)}
freeSolo={inputParam.freeSolo}
onSelect={(newValue) => handleDataChange({ inputParam, newValue })}
value={data.inputs[inputParam.name] ?? inputParam.default ?? 'choose an option'}
/>
</div>
)}
{inputParam.type === 'multiOptions' && (
<MultiDropdown
disabled={disabled}
name={inputParam.name}
options={getDropdownOptions(inputParam)}
onSelect={(newValue) => (data.inputs[inputParam.name] = newValue)}
value={data.inputs[inputParam.name] ?? inputParam.default ?? 'choose an option'}
/>
<div key={`${data.id}_${JSON.stringify(data.inputs[inputParam.name])}`}>
<MultiDropdown
disabled={disabled}
name={inputParam.name}
options={getDropdownOptions(inputParam)}
onSelect={(newValue) => handleDataChange({ inputParam, newValue })}
value={data.inputs[inputParam.name] ?? inputParam.default ?? 'choose an option'}
/>
</div>
)}
{(inputParam.type === 'asyncOptions' || inputParam.type === 'asyncMultiOptions') && (
<>
{data.inputParams.length === 1 && <div style={{ marginTop: 10 }} />}
<div key={reloadTimestamp} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 1 }}>
<div
key={`${reloadTimestamp}_${data.id}_${JSON.stringify(data.inputs[inputParam.name])}`}
style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 1 }}
>
<AsyncDropdown
disabled={disabled}
name={inputParam.name}
@@ -807,7 +1171,10 @@ const NodeInputHandler = ({
freeSolo={inputParam.freeSolo}
multiple={inputParam.type === 'asyncMultiOptions'}
isCreateNewOption={EDITABLE_OPTIONS.includes(inputParam.name)}
onSelect={(newValue) => (data.inputs[inputParam.name] = newValue)}
onSelect={(newValue) => {
if (inputParam.loadConfig) setReloadTimestamp(Date.now().toString())
handleDataChange({ inputParam, newValue })
}}
onCreateNew={() => addAsyncOption(inputParam.name)}
/>
{EDITABLE_OPTIONS.includes(inputParam.name) && data.inputs[inputParam.name] && (
@@ -833,6 +1200,7 @@ const NodeInputHandler = ({
</div>
</>
)}
{inputParam.type === 'array' && <ArrayRenderer inputParam={inputParam} data={data} disabled={disabled} />}
{/* CUSTOM INPUT LOGIC */}
{inputParam.type.includes('conditionFunction') && (
<>
@@ -883,6 +1251,20 @@ const NodeInputHandler = ({
/>
</>
)}
{inputParam.loadConfig && data && data.inputs && data.inputs[inputParam.name] && (
<>
<ConfigInput
key={`${data.id}_${JSON.stringify(data.inputs[inputParam.name])}_${arrayIndex}_${
parentParamForArray?.name
}`}
data={data}
inputParam={inputParam}
disabled={disabled}
arrayIndex={arrayIndex}
parentParamForArray={parentParamForArray}
/>
</>
)}
</Box>
</>
)}
@@ -905,6 +1287,13 @@ const NodeInputHandler = ({
onConfirm={(newValue, inputParamName) => onExpandDialogSave(newValue, inputParamName)}
onInputHintDialogClicked={onInputHintDialogClicked}
></ExpandTextDialog>
<ExpandRichInputDialog
show={showExpandRichDialog}
dialogProps={expandRichDialogProps}
onCancel={() => setShowExpandRichDialog(false)}
onConfirm={(newValue, inputParamName) => onExpandRichDialogSave(newValue, inputParamName)}
onInputHintDialogClicked={onInputHintDialogClicked}
></ExpandRichInputDialog>
<ConditionDialog
show={showConditionDialog}
dialogProps={conditionDialogProps}
@@ -924,6 +1313,100 @@ const NodeInputHandler = ({
onClose={() => setIsNvidiaNIMDialogOpen(false)}
onComplete={handleNvidiaNIMDialogComplete}
></NvidiaNIMDialog>
<Dialog
open={modelSelectionDialogOpen}
onClose={() => {
setModelSelectionDialogOpen(false)
setSelectedTempChatModel({})
}}
aria-labelledby='model-selection-dialog-title'
maxWidth='sm'
fullWidth
>
<DialogTitle id='model-selection-dialog-title'>Select Model</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
<Box sx={{ px: 2 }}>
<Dropdown
name={'chatModel'}
options={availableChatModelsOptions ?? []}
onSelect={(newValue) => {
if (!newValue) {
setSelectedTempChatModel({})
} else {
const foundChatComponent = availableChatModels.find((chatModel) => chatModel.name === newValue)
if (foundChatComponent) {
const chatModelId = `${foundChatComponent.name}_0`
const clonedComponent = cloneDeep(foundChatComponent)
const initChatModelData = initNode(clonedComponent, chatModelId)
setSelectedTempChatModel(initChatModelData)
}
}
}}
value={selectedTempChatModel?.name ?? 'choose an option'}
/>
</Box>
{selectedTempChatModel && Object.keys(selectedTempChatModel).length > 0 && (
<Box sx={{ mt: 2 }}>
{(selectedTempChatModel.inputParams ?? [])
.filter((inputParam) => !inputParam.hidden)
.map((inputParam, index) => (
<DocStoreInputHandler key={index} inputParam={inputParam} data={selectedTempChatModel} />
))}
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setModelSelectionDialogOpen(false)
setSelectedTempChatModel({})
}}
>
Cancel
</Button>
<Button
disabled={!selectedTempChatModel || Object.keys(selectedTempChatModel).length === 0}
onClick={async () => {
setModelSelectionDialogOpen(false)
if (modelSelectionCallback) {
await modelSelectionCallback(selectedTempChatModel)
}
setSelectedTempChatModel({})
}}
variant='contained'
>
Confirm
</Button>
</DialogActions>
</Dialog>
<PromptGeneratorDialog
show={promptGeneratorDialogOpen}
dialogProps={promptGeneratorDialogProps}
onCancel={() => setPromptGeneratorDialogOpen(false)}
onConfirm={(generatedInstruction) => {
try {
data.inputs[inputParam.name] = generatedInstruction
setPromptGeneratorDialogOpen(false)
} catch (error) {
enqueueSnackbar({
message: 'Error setting generated instruction',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
persist: true,
action: (key) => (
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
<IconX />
</Button>
)
}
})
}
}}
/>
{loading && <BackdropLoader open={loading} />}
</div>
)
}
@@ -935,6 +1418,9 @@ NodeInputHandler.propTypes = {
disabled: PropTypes.bool,
isAdditionalParams: PropTypes.bool,
disablePadding: PropTypes.bool,
parentParamForArray: PropTypes.object,
arrayIndex: PropTypes.number,
onCustomDataChange: PropTypes.func,
onHideNodeInfoDialog: PropTypes.func
}
+9 -3
View File
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types'
import { useContext, useState } from 'react'
import { useContext, useState, memo } from 'react'
import { useSelector } from 'react-redux'
// material-ui
@@ -31,13 +31,19 @@ const StickyNote = ({ data }) => {
setOpen(true)
}
const getBorderColor = () => {
if (data.selected) return theme.palette.primary.main
else if (theme?.customization?.isDarkMode) return theme.palette.grey[900] + 25
else return theme.palette.grey[900] + 50
}
return (
<>
<NodeCardWrapper
content={false}
sx={{
padding: 0,
borderColor: data.selected ? theme.palette.primary.main : theme.palette.text.secondary,
borderColor: getBorderColor(),
backgroundColor: data.selected ? '#FFDC00' : '#FFE770'
}}
border={false}
@@ -100,4 +106,4 @@ StickyNote.propTypes = {
data: PropTypes.object
}
export default StickyNote
export default memo(StickyNote)
+2 -2
View File
@@ -24,8 +24,8 @@ import StickyNote from './StickyNote'
import CanvasHeader from './CanvasHeader'
import AddNodes from './AddNodes'
import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog'
import { ChatPopUp } from '@/views/chatmessage/ChatPopUp'
import { VectorStorePopUp } from '@/views/vectorstore/VectorStorePopUp'
import ChatPopUp from '@/views/chatmessage/ChatPopUp'
import VectorStorePopUp from '@/views/vectorstore/VectorStorePopUp'
import { flowContext } from '@/store/context/ReactFlowContext'
// API
+31 -3
View File
@@ -11,6 +11,11 @@ import chatflowsApi from '@/api/chatflows'
// Hooks
import useApi from '@/hooks/useApi'
// MUI
import { Box, Card, Stack, Typography, useTheme } from '@mui/material'
import { IconCircleXFilled } from '@tabler/icons-react'
import { alpha } from '@mui/material/styles'
//Const
import { baseURL } from '@/store/constant'
@@ -20,6 +25,7 @@ const ChatbotFull = () => {
const URLpath = document.location.pathname.toString().split('/')
const chatflowId = URLpath[URLpath.length - 1] === 'chatbot' ? '' : URLpath[URLpath.length - 1]
const navigate = useNavigate()
const theme = useTheme()
const [chatflow, setChatflow] = useState(null)
const [chatbotTheme, setChatbotTheme] = useState({})
@@ -80,7 +86,7 @@ const ChatbotFull = () => {
const chatflowType = chatflowData.type
if (chatflowData.chatbotConfig) {
let parsedConfig = {}
if (chatflowType === 'MULTIAGENT') {
if (chatflowType === 'MULTIAGENT' || chatflowType === 'AGENTFLOW') {
parsedConfig.showAgentMessages = true
}
@@ -99,7 +105,7 @@ const ChatbotFull = () => {
setChatbotTheme(parsedConfig)
setChatbotOverrideConfig({})
}
} else if (chatflowType === 'MULTIAGENT') {
} else if (chatflowType === 'MULTIAGENT' || chatflowType === 'AGENTFLOW') {
setChatbotTheme({ showAgentMessages: true })
}
}
@@ -114,7 +120,29 @@ const ChatbotFull = () => {
{!isLoading ? (
<>
{!chatflow || chatflow.apikeyid ? (
<p>Invalid Chatbot</p>
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '80vh' }}>
<Box sx={{ maxWidth: '500px', width: '100%' }}>
<Card
variant='outlined'
sx={{
border: `1px solid ${theme.palette.error.main}`,
borderRadius: 2,
padding: '20px',
boxShadow: `0 4px 8px ${alpha(theme.palette.error.main, 0.15)}`
}}
>
<Stack spacing={2} alignItems='center'>
<IconCircleXFilled size={50} color={theme.palette.error.main} />
<Typography variant='h3' color='error.main' align='center'>
Invalid Chatbot
</Typography>
<Typography variant='body1' color='text.secondary' align='center'>
{`The chatbot you're looking for doesn't exist or requires API key authentication.`}
</Typography>
</Stack>
</Card>
</Box>
</Box>
) : (
<FullPageChat
chatflowid={chatflow.id}
+7 -1
View File
@@ -131,7 +131,13 @@ const Chatflows = () => {
<ErrorBoundary error={error} />
) : (
<Stack flexDirection='column' sx={{ gap: 3 }}>
<ViewHeader onSearchChange={onSearchChange} search={true} searchPlaceholder='Search Name or Category' title='Chatflows'>
<ViewHeader
onSearchChange={onSearchChange}
search={true}
searchPlaceholder='Search Name or Category'
title='Chatflows'
description='Build single-agent systems, chatbots and simple LLM flows'
>
<ToggleButtonGroup
sx={{ borderRadius: 2, maxHeight: 40 }}
value={view}
@@ -0,0 +1,711 @@
import { useEffect, useState, useCallback, forwardRef, memo } from 'react'
import PropTypes from 'prop-types'
// MUI
import { RichTreeView } from '@mui/x-tree-view/RichTreeView'
import {
Typography,
Box,
Accordion,
AccordionSummary,
AccordionDetails,
Divider,
Button,
Dialog,
DialogContent,
DialogActions
} from '@mui/material'
import { styled, alpha } from '@mui/material/styles'
import { useTreeItem2 } from '@mui/x-tree-view/useTreeItem2'
import {
TreeItem2Content,
TreeItem2IconContainer,
TreeItem2GroupTransition,
TreeItem2Label,
TreeItem2Root,
TreeItem2Checkbox
} from '@mui/x-tree-view/TreeItem2'
import { TreeItem2Icon } from '@mui/x-tree-view/TreeItem2Icon'
import { TreeItem2Provider } from '@mui/x-tree-view/TreeItem2Provider'
import { TreeItem2DragAndDropOverlay } from '@mui/x-tree-view/TreeItem2DragAndDropOverlay'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import ErrorIcon from '@mui/icons-material/Error'
import { IconButton } from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { IconArrowsMaximize, IconLoader, IconCircleXFilled, IconRelationOneToManyFilled } from '@tabler/icons-react'
// Project imports
import { useTheme } from '@mui/material/styles'
import { FLOWISE_CREDENTIAL_ID, AGENTFLOW_ICONS } from '@/store/constant'
import { NodeExecutionDetails } from '@/views/agentexecutions/NodeExecutionDetails'
const getIconColor = (status) => {
switch (status) {
case 'FINISHED':
return 'success.dark'
case 'ERROR':
case 'TIMEOUT':
return 'error.main'
case 'TERMINATED':
case 'STOPPED':
return 'error.main'
case 'INPROGRESS':
return 'warning.dark'
}
}
const StyledTreeItemRoot = styled(TreeItem2Root)(({ theme }) => ({
color: theme.palette.grey[400]
}))
const CustomTreeItemContent = styled(TreeItem2Content)(({ theme }) => ({
flexDirection: 'row-reverse',
borderRadius: theme.spacing(0.7),
marginBottom: theme.spacing(0.5),
marginTop: theme.spacing(0.5),
padding: theme.spacing(0.5),
paddingRight: theme.spacing(1),
fontWeight: 500,
[`&.Mui-expanded `]: {
'&:not(.Mui-focused, .Mui-selected, .Mui-selected.Mui-focused) .labelIcon': {
color: theme.palette.primary.dark,
...theme.applyStyles('light', {
color: theme.palette.primary.main
})
},
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
left: '16px',
top: '44px',
height: 'calc(100% - 48px)',
width: '1.5px',
backgroundColor: theme.palette.grey[700],
...theme.applyStyles('light', {
backgroundColor: theme.palette.grey[300]
})
}
},
'&:hover': {
backgroundColor: alpha(theme.palette.primary.main, 0.1),
color: 'white',
...theme.applyStyles('light', {
color: theme.palette.primary.main
})
},
[`&.Mui-focused, &.Mui-selected, &.Mui-selected.Mui-focused`]: {
backgroundColor: theme.palette.primary.dark,
color: theme.palette.primary.contrastText,
...theme.applyStyles('light', {
backgroundColor: theme.palette.primary.main
})
}
}))
const StyledTreeItemLabelText = styled(Typography)(({ theme }) => ({
color: theme.palette.text.primary
}))
function CustomLabel({ icon: Icon, itemStatus, children, name, label, data, metadata, ...other }) {
const [openDialog, setOpenDialog] = useState(false)
const handleOpenDialog = (event) => {
// Stop propagation to prevent parent elements from capturing the click
event.stopPropagation()
setOpenDialog(true)
}
const handleCloseDialog = () => setOpenDialog(false)
// Check if this is an iteration node
const isIterationNode = name === 'iterationAgentflow'
return (
<TreeItem2Label
{...other}
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
{(() => {
// Display iteration icon for iteration nodes
if (isIterationNode) {
return (
<Box
sx={{
mr: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<IconRelationOneToManyFilled size={20} color={'#9C89B8'} />
</Box>
)
}
// Otherwise display the node icon
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === name)
if (foundIcon) {
return (
<Box
sx={{
mr: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<foundIcon.icon size={20} color={foundIcon.color} />
</Box>
)
}
return null
})()}
<StyledTreeItemLabelText sx={{ flex: 1 }}>{children}</StyledTreeItemLabelText>
<IconButton
onClick={handleOpenDialog}
size='small'
title='View Details'
sx={{
ml: 2,
zIndex: 10 // Increase z-index to ensure the button is clickable
}}
>
<IconArrowsMaximize size={15} color={'teal'} />
</IconButton>
{Icon && <Box component={Icon} className='labelIcon' color={getIconColor(itemStatus)} sx={{ ml: 1, fontSize: '1.2rem' }} />}
</Box>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth='md' fullWidth disableBackdropClick={true}>
<DialogContent onClick={(e) => e.stopPropagation()}>
{data ? (
<NodeExecutionDetails data={data} label={label} metadata={metadata} />
) : (
<Typography color='text.secondary'>No data available for this item</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Close</Button>
</DialogActions>
</Dialog>
</TreeItem2Label>
)
}
CustomLabel.propTypes = {
icon: PropTypes.elementType,
itemStatus: PropTypes.string,
expandable: PropTypes.bool,
children: PropTypes.node,
name: PropTypes.string,
label: PropTypes.string,
status: PropTypes.object,
data: PropTypes.object,
metadata: PropTypes.object
}
CustomLabel.displayName = 'CustomLabel'
const isExpandable = (reactChildren) => {
if (Array.isArray(reactChildren)) {
return reactChildren.length > 0 && reactChildren.some(isExpandable)
}
return Boolean(reactChildren)
}
const getIconFromStatus = (status, theme) => {
switch (status) {
case 'FINISHED':
return CheckCircleIcon
case 'ERROR':
case 'TIMEOUT':
return ErrorIcon
case 'TERMINATED':
// eslint-disable-next-line react/display-name
return (props) => <IconCircleXFilled {...props} color={theme.palette.error.main} />
case 'STOPPED':
return StopCircleIcon
case 'INPROGRESS':
// eslint-disable-next-line react/display-name
return (props) => (
// eslint-disable-next-line
<IconLoader {...props} color={theme.palette.warning.dark} className={`spin-animation ${props.className || ''}`} />
)
}
}
const CustomTreeItem = forwardRef(function CustomTreeItem(props, ref) {
const { id, itemId, label, disabled, children, agentflowId, sessionId, ...other } = props
const theme = useTheme()
const {
getRootProps,
getContentProps,
getIconContainerProps,
getCheckboxProps,
getLabelProps,
getGroupTransitionProps,
getDragAndDropOverlayProps,
status,
publicAPI
} = useTreeItem2({ id, itemId, children, label, disabled, rootRef: ref })
const item = publicAPI.getItem(itemId)
const expandable = isExpandable(children)
let icon
if (item.status) {
icon = getIconFromStatus(item.status, theme)
}
return (
<TreeItem2Provider itemId={itemId}>
<StyledTreeItemRoot {...getRootProps(other)}>
<CustomTreeItemContent {...getContentProps()}>
<TreeItem2IconContainer {...getIconContainerProps()}>
<TreeItem2Icon status={status} />
</TreeItem2IconContainer>
<TreeItem2Checkbox {...getCheckboxProps()} />
<CustomLabel
{...getLabelProps({
icon,
itemStatus: item.status,
expandable: expandable && status.expanded,
name: item.name || item.id?.split('_')[0],
label: item.label,
status,
data: item.data,
metadata: { agentflowId, sessionId }
})}
/>
<TreeItem2DragAndDropOverlay {...getDragAndDropOverlayProps()} />
</CustomTreeItemContent>
{children && (
<TreeItem2GroupTransition
{...getGroupTransitionProps()}
style={{
borderLeft: `${status.selected ? '3px solid' : '1px dashed'} ${(() => {
const nodeName = item.name || item.id?.split('_')[0]
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === nodeName)
return foundIcon ? foundIcon.color : theme.palette.primary.main
})()}`,
marginLeft: '13px',
paddingLeft: '8px'
}}
/>
)}
</StyledTreeItemRoot>
</TreeItem2Provider>
)
})
CustomTreeItem.propTypes = {
id: PropTypes.string,
itemId: PropTypes.string,
label: PropTypes.string,
disabled: PropTypes.bool,
children: PropTypes.node,
agentflowId: PropTypes.string,
sessionId: PropTypes.string,
className: PropTypes.string
}
CustomTreeItem.displayName = 'CustomTreeItem'
const AgentExecutedDataCard = ({ status, execution, agentflowId, sessionId }) => {
const [executionTree, setExecution] = useState([])
const [expandedItems, setExpandedItems] = useState([])
const [selectedItem, setSelectedItem] = useState(null)
const theme = useTheme()
const getAllNodeIds = (nodes) => {
let ids = []
nodes.forEach((node) => {
ids.push(node.id)
if (node.children && node.children.length > 0) {
ids = [...ids, ...getAllNodeIds(node.children)]
}
})
return ids
}
// Transform the execution data into a tree structure
const buildTreeData = (nodes) => {
// for each node, loop through each and every nested key of node.data, and remove the key if it is equal to FLOWISE_CREDENTIAL_ID
nodes.forEach((node) => {
const removeFlowiseCredentialId = (data) => {
for (const key in data) {
if (key === FLOWISE_CREDENTIAL_ID) {
delete data[key]
}
if (typeof data[key] === 'object') {
removeFlowiseCredentialId(data[key])
}
}
}
removeFlowiseCredentialId(node.data)
})
// Create a map for quick node lookup
// Use execution index to make each node instance unique
const nodeMap = new Map()
nodes.forEach((node, index) => {
const uniqueNodeId = `${node.nodeId}_${index}`
nodeMap.set(uniqueNodeId, { ...node, uniqueNodeId, children: [], executionIndex: index })
})
// Identify iteration nodes and their children
const iterationGroups = new Map() // parentId -> Map of iterationIndex -> nodes
// Group iteration child nodes by their parent and iteration index
nodes.forEach((node, index) => {
if (node.data?.parentNodeId && node.data?.iterationIndex !== undefined) {
const parentId = node.data.parentNodeId
const iterationIndex = node.data.iterationIndex
if (!iterationGroups.has(parentId)) {
iterationGroups.set(parentId, new Map())
}
const iterationMap = iterationGroups.get(parentId)
if (!iterationMap.has(iterationIndex)) {
iterationMap.set(iterationIndex, [])
}
iterationMap.get(iterationIndex).push(`${node.nodeId}_${index}`)
}
})
// Create virtual iteration container nodes
iterationGroups.forEach((iterationMap, parentId) => {
iterationMap.forEach((nodeIds, iterationIndex) => {
// Find the parent iteration node
let parentNode = null
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].nodeId === parentId) {
parentNode = nodes[i]
break
}
}
if (!parentNode) return
// Get iteration context from first child node
const firstChildId = nodeIds[0]
const firstChild = nodeMap.get(firstChildId)
const iterationContext = firstChild?.data?.iterationContext || { index: iterationIndex }
// Create a virtual node for this iteration
const iterationNodeId = `${parentId}_${iterationIndex}`
const iterationLabel = `Iteration #${iterationIndex}`
// Determine status based on child nodes
const childNodes = nodeIds.map((id) => nodeMap.get(id))
const iterationStatus = childNodes.some((n) => n.status === 'ERROR')
? 'ERROR'
: childNodes.some((n) => n.status === 'INPROGRESS')
? 'INPROGRESS'
: childNodes.every((n) => n.status === 'FINISHED')
? 'FINISHED'
: 'UNKNOWN'
// Create the virtual node and add to nodeMap
const virtualNode = {
nodeId: iterationNodeId,
nodeLabel: iterationLabel,
data: {
name: 'iterationAgentflow',
iterationIndex,
iterationContext,
isVirtualNode: true,
parentIterationId: parentId
},
previousNodeIds: [], // Will be handled in the main tree building
status: iterationStatus,
uniqueNodeId: iterationNodeId,
children: [],
executionIndex: -1 // Flag as a virtual node
}
nodeMap.set(iterationNodeId, virtualNode)
// Set this virtual node as the parent for all nodes in this iteration
nodeIds.forEach((childId) => {
const childNode = nodeMap.get(childId)
if (childNode) {
childNode.virtualParentId = iterationNodeId
}
})
})
})
// Root nodes have no previous nodes
const rootNodes = []
const processedNodes = new Set()
// First pass: Build the main tree structure (excluding iteration children)
nodes.forEach((node, index) => {
const uniqueNodeId = `${node.nodeId}_${index}`
const treeNode = nodeMap.get(uniqueNodeId)
// Skip nodes that belong to an iteration (they'll be added to their virtual parent)
if (node.data?.parentNodeId && node.data?.iterationIndex !== undefined) {
return
}
if (node.previousNodeIds.length === 0) {
rootNodes.push(treeNode)
} else {
// Find the most recent (latest) parent node among all previous nodes
let mostRecentParentIndex = -1
let mostRecentParentId = null
node.previousNodeIds.forEach((parentId) => {
// Find the most recent instance of this parent node
for (let i = 0; i < index; i++) {
if (nodes[i].nodeId === parentId && i > mostRecentParentIndex) {
mostRecentParentIndex = i
mostRecentParentId = parentId
}
}
})
// Only add to the most recent parent
if (mostRecentParentIndex !== -1) {
const parentUniqueId = `${mostRecentParentId}_${mostRecentParentIndex}`
const parentNode = nodeMap.get(parentUniqueId)
if (parentNode) {
parentNode.children.push(treeNode)
processedNodes.add(uniqueNodeId)
}
}
}
})
// Second pass: Build the iteration sub-trees
iterationGroups.forEach((iterationMap, parentId) => {
// Find all instances of the parent node
const parentInstances = []
nodes.forEach((node, index) => {
if (node.nodeId === parentId) {
parentInstances.push(`${node.nodeId}_${index}`)
}
})
// Find the latest instance of the parent node that exists in the tree
let latestParent = null
for (let i = parentInstances.length - 1; i >= 0; i--) {
const parentId = parentInstances[i]
const parent = nodeMap.get(parentId)
if (parent) {
latestParent = parent
break
}
}
if (!latestParent) return
// Add all virtual iteration nodes to the parent
iterationMap.forEach((nodeIds, iterationIndex) => {
const iterationNodeId = `${parentId}_${iterationIndex}`
const virtualNode = nodeMap.get(iterationNodeId)
if (virtualNode) {
latestParent.children.push(virtualNode)
}
})
})
// Third pass: Build the structure inside each virtual iteration node
nodeMap.forEach((node) => {
if (node.virtualParentId) {
const virtualParent = nodeMap.get(node.virtualParentId)
if (virtualParent) {
if (node.previousNodeIds.length === 0) {
// This is a root node within the iteration
virtualParent.children.push(node)
} else {
// Find its parent within the same iteration
let parentFound = false
for (const prevNodeId of node.previousNodeIds) {
// Look for nodes with the same previous node ID in the same iteration
nodeMap.forEach((potentialParent) => {
if (
potentialParent.nodeId === prevNodeId &&
potentialParent.data?.iterationIndex === node.data?.iterationIndex &&
potentialParent.data?.parentNodeId === node.data?.parentNodeId &&
!parentFound
) {
potentialParent.children.push(node)
parentFound = true
}
})
}
// If no parent was found within the iteration, add directly to virtual parent
if (!parentFound) {
virtualParent.children.push(node)
}
}
}
}
})
// Final pass: Sort all children arrays to ensure iteration nodes appear first
const sortChildrenNodes = (node) => {
if (node.children && node.children.length > 0) {
// Sort children: iteration nodes first, then others by their original execution order
node.children.sort((a, b) => {
// Check if a is an iteration node
const aIsIteration = a.data?.name === 'iterationAgentflow' || a.data?.isVirtualNode
// Check if b is an iteration node
const bIsIteration = b.data?.name === 'iterationAgentflow' || b.data?.isVirtualNode
// If both are iterations or both are not iterations, preserve original order
if (aIsIteration === bIsIteration) {
return a.executionIndex - b.executionIndex
}
// Otherwise, put iterations first
return aIsIteration ? -1 : 1
})
// Recursively sort children's children
node.children.forEach(sortChildrenNodes)
}
}
// Apply sorting to all root nodes and their children
rootNodes.forEach(sortChildrenNodes)
// Transform to the required format
const transformNode = (node) => ({
id: node.uniqueNodeId,
label: node.nodeLabel,
name: node.data?.name,
status: node.status,
data: node.data,
children: node.children.map(transformNode)
})
return rootNodes.map(transformNode)
}
const handleExpandedItemsChange = (event, itemIds) => {
setExpandedItems(itemIds)
}
useEffect(() => {
if (execution) {
const newTree = buildTreeData(execution)
setExecution(newTree)
setExpandedItems(getAllNodeIds(newTree))
// Set the first item as default selected item
if (newTree.length > 0) {
setSelectedItem(newTree[0])
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [execution])
const handleNodeSelect = (event, itemId) => {
const findNode = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children) {
const found = findNode(node.children, id)
if (found) return found
}
}
return null
}
const selectedNode = findNode(executionTree, itemId)
setSelectedItem(selectedNode)
}
const getExecutionStatus = useCallback((executionTree) => {
const getAllStatuses = (nodes) => {
let statuses = []
nodes.forEach((node) => {
if (node.status) statuses.push(node.status)
if (node.children && node.children.length > 0) {
statuses = [...statuses, ...getAllStatuses(node.children)]
}
})
return statuses
}
const statuses = getAllStatuses(executionTree)
if (statuses.includes('ERROR')) return 'ERROR'
if (statuses.includes('INPROGRESS')) return 'INPROGRESS'
if (statuses.includes('STOPPED')) return 'STOPPED'
if (statuses.every((status) => status === 'FINISHED')) return 'FINISHED'
return null
}, [])
return (
<Box sx={{ display: 'flex', height: '100%', width: '100%', mt: 2 }}>
<Accordion
sx={{
width: '100%'
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
'& .MuiAccordionSummary-content': {
alignItems: 'center'
}
}}
>
{executionTree.length > 0 &&
(() => {
const execStatus = status ?? getExecutionStatus(executionTree)
return (
<Box sx={{ mr: 1, fontSize: '1.2rem' }}>
<Box
component={getIconFromStatus(execStatus, theme)}
sx={{
mr: 1,
fontSize: '1.2rem',
color: getIconColor(execStatus)
}}
/>
</Box>
)
})()}
<Typography>Process Flow</Typography>
</AccordionSummary>
<Divider />
<AccordionDetails>
<RichTreeView
expandedItems={expandedItems}
onExpandedItemsChange={handleExpandedItemsChange}
selectedItems={selectedItem ? [selectedItem.id] : []}
onSelectedItemsChange={handleNodeSelect}
items={executionTree}
slots={{
item: (treeItemProps) => <CustomTreeItem {...treeItemProps} agentflowId={agentflowId} sessionId={sessionId} />
}}
sx={{ width: '100%' }}
/>
</AccordionDetails>
</Accordion>
</Box>
)
}
AgentExecutedDataCard.propTypes = {
status: PropTypes.string,
execution: PropTypes.array,
agentflowId: PropTypes.string,
sessionId: PropTypes.string
}
export default memo(AgentExecutedDataCard)
@@ -0,0 +1,183 @@
import { Box, Card, CardContent, Chip, Stack } from '@mui/material'
import { IconTool, IconDeviceSdCard } from '@tabler/icons-react'
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import nextAgentGIF from '@/assets/images/next-agent.gif'
import PropTypes from 'prop-types'
const AgentReasoningCard = ({
agent,
index,
customization,
chatflowid,
isDialog,
onSourceDialogClick,
renderArtifacts,
agentReasoningArtifacts,
getAgentIcon,
removeDuplicateURL,
isValidURL,
onURLClick,
getLabel
}) => {
if (agent.nextAgent) {
return (
<Card
key={index}
sx={{
border: customization.isDarkMode ? 'none' : '1px solid #e0e0e0',
borderRadius: `${customization.borderRadius}px`,
background: customization.isDarkMode
? `linear-gradient(to top, #303030, #212121)`
: `linear-gradient(to top, #f6f3fb, #f2f8fc)`,
mb: 1
}}
>
<CardContent>
<Stack
sx={{
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%'
}}
flexDirection='row'
>
<Box sx={{ height: 'auto', pr: 1 }}>
<img
style={{
objectFit: 'cover',
height: '35px',
width: 'auto'
}}
src={nextAgentGIF}
alt='agentPNG'
/>
</Box>
<div>{agent.nextAgent}</div>
</Stack>
</CardContent>
</Card>
)
}
return (
<Card key={index} sx={{ mb: 1 }}>
<CardContent>
<Stack
sx={{
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%'
}}
flexDirection='row'
>
<Box sx={{ height: 'auto', pr: 1 }}>
<img
style={{
objectFit: 'cover',
height: '25px',
width: 'auto'
}}
src={getAgentIcon(agent.nodeName, agent.instructions)}
alt='agentPNG'
/>
</Box>
<div>{agent.agentName}</div>
</Stack>
{agent.usedTools && agent.usedTools.length > 0 && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{agent.usedTools.map((tool, index) => {
return tool !== null ? (
<Chip
size='small'
key={index}
label={tool.tool}
component='a'
sx={{ mr: 1, mt: 1 }}
variant='outlined'
clickable
icon={<IconTool size={15} />}
onClick={() => onSourceDialogClick(tool, 'Used Tools')}
/>
) : null
})}
</div>
)}
{agent.state && Object.keys(agent.state).length > 0 && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
<Chip
size='small'
label={'State'}
component='a'
sx={{ mr: 1, mt: 1 }}
variant='outlined'
clickable
icon={<IconDeviceSdCard size={15} />}
onClick={() => onSourceDialogClick(agent.state, 'State')}
/>
</div>
)}
{agent.artifacts && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
flexDirection: 'row',
width: '100%',
gap: '8px'
}}
>
{agentReasoningArtifacts(agent.artifacts).map((item, index) => {
return item !== null ? <>{renderArtifacts(item, index, true)}</> : null
})}
</div>
)}
{agent.messages.length > 0 && (
<MemoizedReactMarkdown chatflowid={chatflowid} isFullWidth={isDialog}>
{agent.messages.length > 1 ? agent.messages.join('\\n') : agent.messages[0]}
</MemoizedReactMarkdown>
)}
{agent.instructions && <p>{agent.instructions}</p>}
{agent.messages.length === 0 && !agent.instructions && <p>Finished</p>}
{agent.sourceDocuments && agent.sourceDocuments.length > 0 && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{removeDuplicateURL(agent).map((source, index) => {
const URL = source && source.metadata && source.metadata.source ? isValidURL(source.metadata.source) : undefined
return (
<Chip
size='small'
key={index}
label={getLabel(URL, source) || ''}
component='a'
sx={{ mr: 1, mb: 1 }}
variant='outlined'
clickable
onClick={() => (URL ? onURLClick(source.metadata.source) : onSourceDialogClick(source))}
/>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
AgentReasoningCard.propTypes = {
agent: PropTypes.object.isRequired,
index: PropTypes.number.isRequired,
customization: PropTypes.object.isRequired,
chatflowid: PropTypes.string,
isDialog: PropTypes.bool,
onSourceDialogClick: PropTypes.func.isRequired,
renderArtifacts: PropTypes.func.isRequired,
agentReasoningArtifacts: PropTypes.func.isRequired,
getAgentIcon: PropTypes.func.isRequired,
removeDuplicateURL: PropTypes.func.isRequired,
isValidURL: PropTypes.func.isRequired,
onURLClick: PropTypes.func.isRequired,
getLabel: PropTypes.func.isRequired
}
AgentReasoningCard.displayName = 'AgentReasoningCard'
export default AgentReasoningCard
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types'
import { useSelector } from 'react-redux'
import { Dialog, DialogContent, DialogTitle, Button } from '@mui/material'
import { ChatMessage } from './ChatMessage'
import ChatMessage from './ChatMessage'
import { StyledButton } from '@/ui-component/button/StyledButton'
import { IconEraser } from '@tabler/icons-react'
+381 -302
View File
@@ -1,11 +1,7 @@
import { useState, useRef, useEffect, useCallback, Fragment } from 'react'
import { useState, useRef, useEffect, useCallback, Fragment, useContext, memo } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import PropTypes from 'prop-types'
import { cloneDeep } from 'lodash'
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { EventStreamContentType, fetchEventSource } from '@microsoft/fetch-event-source'
@@ -22,10 +18,14 @@ import {
InputAdornment,
OutlinedInput,
Typography,
CardContent,
Stack
Stack,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { darken, useTheme } from '@mui/material/styles'
import {
IconCircleDot,
IconDownload,
@@ -36,7 +36,6 @@ import {
IconX,
IconTool,
IconSquareFilled,
IconDeviceSdCard,
IconCheck,
IconPaperclip,
IconSparkles
@@ -46,14 +45,15 @@ import userPNG from '@/assets/images/account.png'
import multiagent_supervisorPNG from '@/assets/images/multiagent_supervisor.png'
import multiagent_workerPNG from '@/assets/images/multiagent_worker.png'
import audioUploadSVG from '@/assets/images/wave-sound.jpg'
import nextAgentGIF from '@/assets/images/next-agent.gif'
// project import
import { CodeBlock } from '@/ui-component/markdown/CodeBlock'
import NodeInputHandler from '@/views/canvas/NodeInputHandler'
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import SourceDocDialog from '@/ui-component/dialog/SourceDocDialog'
import ChatFeedbackContentDialog from '@/ui-component/dialog/ChatFeedbackContentDialog'
import StarterPromptsCard from '@/ui-component/cards/StarterPromptsCard'
import AgentReasoningCard from './AgentReasoningCard'
import AgentExecutedDataCard from './AgentExecutedDataCard'
import { ImageButton, ImageSrc, ImageBackdrop, ImageMarked } from '@/ui-component/button/ImageButton'
import CopyToClipboardButton from '@/ui-component/button/CopyToClipboardButton'
import ThumbsUpButton from '@/ui-component/button/ThumbsUpButton'
@@ -70,9 +70,11 @@ import vectorstoreApi from '@/api/vectorstore'
import attachmentsApi from '@/api/attachments'
import chatmessagefeedbackApi from '@/api/chatmessagefeedback'
import leadsApi from '@/api/lead'
import executionsApi from '@/api/executions'
// Hooks
import useApi from '@/hooks/useApi'
import { flowContext } from '@/store/context/ReactFlowContext'
// Const
import { baseURL, maxScroll } from '@/store/constant'
@@ -159,13 +161,14 @@ CardWithDeleteOverlay.propTypes = {
onDelete: PropTypes.func
}
export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, previews, setPreviews }) => {
const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, previews, setPreviews }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const ps = useRef()
const dispatch = useDispatch()
const { onAgentflowNodeStatusUpdate, clearAgentflowNodeStatus } = useContext(flowContext)
useNotifier()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
@@ -192,6 +195,7 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
const inputRef = useRef(null)
const getChatmessageApi = useApi(chatmessageApi.getInternalChatmessageFromChatflow)
const getAllExecutionsApi = useApi(executionsApi.getAllExecutions)
const getIsChatflowStreamingApi = useApi(chatflowsApi.getIsChatflowStreaming)
const getAllowChatFlowUploads = useApi(chatflowsApi.getAllowChatflowUploads)
const getChatflowConfig = useApi(chatflowsApi.getSpecificChatflow)
@@ -232,6 +236,20 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
const [recordingNotSupported, setRecordingNotSupported] = useState(false)
const [isLoadingRecording, setIsLoadingRecording] = useState(false)
const [openFeedbackDialog, setOpenFeedbackDialog] = useState(false)
const [feedback, setFeedback] = useState('')
const [pendingActionData, setPendingActionData] = useState(null)
const [feedbackType, setFeedbackType] = useState('')
// start input type
const [startInputType, setStartInputType] = useState('')
const [formTitle, setFormTitle] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formInputsData, setFormInputsData] = useState({})
const [formInputParams, setFormInputParams] = useState([])
const [isConfigLoading, setIsConfigLoading] = useState(true)
const isFileAllowedForUpload = (file) => {
const constraints = getAllowChatFlowUploads.data
/**
@@ -555,6 +573,28 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
})
}
const updateAgentFlowEvent = (event) => {
if (event === 'INPROGRESS') {
setMessages((prevMessages) => [...prevMessages, { message: '', type: 'apiMessage', agentFlowEventStatus: event }])
} else {
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages
allMessages[allMessages.length - 1].agentFlowEventStatus = event
return allMessages
})
}
}
const updateAgentFlowExecutedData = (agentFlowExecutedData) => {
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages
allMessages[allMessages.length - 1].agentFlowExecutedData = agentFlowExecutedData
return allMessages
})
}
const updateLastMessageAction = (action) => {
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
@@ -594,6 +634,28 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
})
}
const updateLastMessageNextAgentFlow = (nextAgentFlow) => {
onAgentflowNodeStatusUpdate(nextAgentFlow)
}
const updateLastMessageUsedTools = (usedTools) => {
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages
allMessages[allMessages.length - 1].usedTools = usedTools
return allMessages
})
}
const updateLastMessageFileAnnotations = (fileAnnotations) => {
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages
allMessages[allMessages.length - 1].fileAnnotations = fileAnnotations
return allMessages
})
}
const abortMessage = () => {
setIsMessageStopping(false)
setMessages((prevMessages) => {
@@ -622,25 +684,6 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
})
}
const updateLastMessageUsedTools = (usedTools) => {
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages
allMessages[allMessages.length - 1].usedTools = usedTools
return allMessages
})
}
const updateLastMessageFileAnnotations = (fileAnnotations) => {
setMessages((prevMessages) => {
let allMessages = [...cloneDeep(prevMessages)]
if (allMessages[allMessages.length - 1].type === 'userMessage') return allMessages
allMessages[allMessages.length - 1].fileAnnotations = fileAnnotations
return allMessages
})
}
// Handle errors
const handleError = (message = 'Oops! There seems to be an error. Please try again.') => {
message = message.replace(`Unable to parse JSON response from chat agent.\n\n`, '')
setMessages((prevMessages) => [...prevMessages, { message, type: 'apiMessage' }])
@@ -663,6 +706,29 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
handleSubmit(undefined, promptStarterInput)
}
const onSubmitResponse = (actionData, feedback = '', type = '') => {
let fbType = feedbackType
if (type) {
fbType = type
}
const question = feedback ? feedback : fbType.charAt(0).toUpperCase() + fbType.slice(1)
handleSubmit(undefined, question, undefined, {
type: fbType,
startNodeId: actionData?.nodeId,
feedback
})
}
const handleSubmitFeedback = () => {
if (pendingActionData) {
onSubmitResponse(pendingActionData, feedback)
setOpenFeedbackDialog(false)
setFeedback('')
setPendingActionData(null)
setFeedbackType('')
}
}
const handleActionClick = async (elem, action) => {
setUserInput(elem.label)
setMessages((prevMessages) => {
@@ -671,7 +737,19 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
allMessages[allMessages.length - 1].action = null
return allMessages
})
handleSubmit(undefined, elem.label, action)
if (elem.type.includes('agentflowv2')) {
const type = elem.type.includes('approve') ? 'proceed' : 'reject'
setFeedbackType(type)
if (action.data && action.data.input && action.data.input.humanInputEnableFeedback) {
setPendingActionData(action.data)
setOpenFeedbackDialog(true)
} else {
onSubmitResponse(action.data, '', type)
}
} else {
handleSubmit(undefined, elem.label, action)
}
}
const updateMetadata = (data, input) => {
@@ -773,7 +851,7 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
}
// Handle form submission
const handleSubmit = async (e, selectedInput, action) => {
const handleSubmit = async (e, selectedInput, action, humanInput) => {
if (e) e.preventDefault()
if (!selectedInput && userInput.trim() === '') {
@@ -785,13 +863,21 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
let input = userInput
if (selectedInput !== undefined && selectedInput.trim() !== '') input = selectedInput
if (typeof selectedInput === 'string') {
if (selectedInput !== undefined && selectedInput.trim() !== '') input = selectedInput
if (input.trim()) {
inputHistory.addToHistory(input)
if (input.trim()) {
inputHistory.addToHistory(input)
}
} else if (typeof selectedInput === 'object') {
input = Object.entries(selectedInput)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
}
setLoading(true)
clearAgentflowNodeStatus()
let uploads = previews.map((item) => {
return {
data: item.data,
@@ -817,9 +903,14 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
question: input,
chatId
}
if (typeof selectedInput === 'object') {
params.form = selectedInput
delete params.question
}
if (uploads && uploads.length > 0) params.uploads = uploads
if (leadEmail) params.leadEmail = leadEmail
if (action) params.action = action
if (humanInput) params.humanInput = humanInput
if (isChatFlowAvailableToStream) {
fetchResponseFromEventStream(chatflowid, params)
@@ -842,8 +933,10 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
id: data?.chatMessageId,
sourceDocuments: data?.sourceDocuments,
usedTools: data?.usedTools,
calledTools: data?.calledTools,
fileAnnotations: data?.fileAnnotations,
agentReasoning: data?.agentReasoning,
agentFlowExecutedData: data?.agentFlowExecutedData,
action: data?.action,
artifacts: data?.artifacts,
type: 'apiMessage',
@@ -908,6 +1001,12 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
case 'agentReasoning':
updateLastMessageAgentReasoning(payload.data)
break
case 'agentFlowEvent':
updateAgentFlowEvent(payload.data)
break
case 'agentFlowExecutedData':
updateAgentFlowExecutedData(payload.data)
break
case 'artifacts':
updateLastMessageArtifacts(payload.data)
break
@@ -917,6 +1016,9 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
case 'nextAgent':
updateLastMessageNextAgent(payload.data)
break
case 'nextAgentFlow':
updateLastMessageNextAgentFlow(payload.data)
break
case 'metadata':
updateMetadata(payload.data, input)
break
@@ -1068,6 +1170,8 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
})
}
if (message.followUpPrompts) obj.followUpPrompts = JSON.parse(message.followUpPrompts)
if (message.role === 'apiMessage' && message.execution && message.execution.executionData)
obj.agentFlowExecutedData = JSON.parse(message.execution.executionData)
return obj
})
setMessages((prevMessages) => [...prevMessages, ...loadedMessages])
@@ -1077,6 +1181,25 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getChatmessageApi.data])
useEffect(() => {
if (getAllExecutionsApi.data?.length) {
const chatId = getAllExecutionsApi.data[0]?.sessionId
setChatId(chatId)
const loadedMessages = getAllExecutionsApi.data.map((execution) => {
const executionData =
typeof execution.executionData === 'string' ? JSON.parse(execution.executionData) : execution.executionData
const obj = {
id: execution.id,
agentFlow: executionData
}
return obj
})
setMessages((prevMessages) => [...prevMessages, ...loadedMessages])
setLocalStorageChatflow(chatflowid, chatId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getAllExecutionsApi.data])
// Get chatflow streaming capability
useEffect(() => {
if (getIsChatflowStreamingApi.data) {
@@ -1099,6 +1222,38 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
useEffect(() => {
if (getChatflowConfig.data) {
setIsConfigLoading(false)
if (getChatflowConfig.data?.flowData) {
let nodes = JSON.parse(getChatflowConfig.data?.flowData).nodes ?? []
const startNode = nodes.find((node) => node.data.name === 'startAgentflow')
if (startNode) {
const startInputType = startNode.data.inputs?.startInputType
setStartInputType(startInputType)
const formInputTypes = startNode.data.inputs?.formInputTypes
if (startInputType === 'formInput' && formInputTypes && formInputTypes.length > 0) {
for (const formInputType of formInputTypes) {
if (formInputType.type === 'options') {
formInputType.options = formInputType.addOptions.map((option) => ({
label: option.option,
name: option.option
}))
}
}
setFormInputParams(formInputTypes)
setFormInputsData({
id: 'formInput',
inputs: {},
inputParams: formInputTypes
})
setFormTitle(startNode.data.inputs?.formTitle)
setFormDescription(startNode.data.inputs?.formDescription)
}
getAllExecutionsApi.request({ agentflowId: chatflowid })
}
}
if (getChatflowConfig.data?.chatbotConfig && JSON.parse(getChatflowConfig.data?.chatbotConfig)) {
let config = JSON.parse(getChatflowConfig.data?.chatbotConfig)
if (config.starterPrompts) {
@@ -1143,6 +1298,13 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getChatflowConfig.data])
useEffect(() => {
if (getChatflowConfig.error) {
setIsConfigLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getChatflowConfig.error])
useEffect(() => {
if (fullFileUpload) {
setIsChatFlowAvailableForFileUploads(true)
@@ -1174,10 +1336,13 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
getAllowChatFlowUploads.request(chatflowid)
getChatflowConfig.request(chatflowid)
// Scroll to bottom
scrollToBottom()
// Add a small delay to ensure content is rendered before scrolling
setTimeout(() => {
scrollToBottom()
}, 100)
setIsRecording(false)
setIsConfigLoading(true)
// leads
const savedLead = getLocalStorageChatflow(chatflowid)?.lead
@@ -1502,35 +1667,124 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
)
} else {
return (
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
key={Math.random()}
chatflowid={chatflowid}
isDialog={isDialog}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
<MemoizedReactMarkdown chatflowid={chatflowid} isFullWidth={isDialog}>
{item.data}
</MemoizedReactMarkdown>
)
}
}
if (isConfigLoading) {
return (
<Box
sx={{
width: '100%',
height: '100%',
position: 'relative',
backgroundColor: theme.palette.background.paper
}}
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
>
<CircularProgress />
</Box>
</Box>
)
}
if (startInputType === 'formInput' && messages.length === 1) {
return (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
backgroundColor: theme.palette.background.paper
}}
>
<Box
sx={{
width: '100%',
height: '100%',
position: 'relative'
}}
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100%',
maxWidth: '600px',
maxHeight: '90%', // Limit height to 90% of parent
p: 3,
backgroundColor: customization.isDarkMode
? darken(theme.palette.background.paper, 0.2)
: theme.palette.background.paper,
boxShadow: customization.isDarkMode ? '0px 0px 15px 0px rgba(255, 255, 255, 0.1)' : theme.shadows[3],
borderRadius: 2,
overflowY: 'auto' // Enable vertical scrolling if content overflows
}}
>
<Typography variant='h4' sx={{ mb: 1, textAlign: 'center' }}>
{formTitle || 'Please Fill Out The Form'}
</Typography>
<Typography variant='body1' sx={{ mb: 3, textAlign: 'center', color: theme.palette.text.secondary }}>
{formDescription || 'Complete all fields below to continue'}
</Typography>
{/* Form inputs */}
<Box sx={{ mb: 3 }}>
{formInputParams &&
formInputParams.map((inputParam, index) => (
<Box key={index} sx={{ mb: 2 }}>
<NodeInputHandler
inputParam={inputParam}
data={formInputsData}
isAdditionalParams={true}
onCustomDataChange={({ inputParam, newValue }) => {
setFormInputsData((prev) => ({
...prev,
inputs: {
...prev.inputs,
[inputParam.name]: newValue
}
}))
}}
/>
</Box>
))}
</Box>
<Button
variant='contained'
fullWidth
disabled={loading}
onClick={() => handleSubmit(null, formInputsData.inputs)}
sx={{
mb: 2,
borderRadius: 20,
background: 'linear-gradient(45deg, #673ab7 30%, #1e88e5 90%)'
}}
>
{loading ? 'Submitting...' : 'Submit'}
</Button>
</Box>
</Box>
</Box>
)
}
return (
<div onDragEnter={handleDrag}>
{isDragActive && (
@@ -1614,224 +1868,37 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
})}
</div>
)}
{message.agentReasoning && (
{message.agentReasoning && message.agentReasoning.length > 0 && (
<div style={{ display: 'block', flexDirection: 'row', width: '100%' }}>
{message.agentReasoning.map((agent, index) => {
return agent.nextAgent ? (
<Card
key={index}
sx={{
border: customization.isDarkMode ? 'none' : '1px solid #e0e0e0',
borderRadius: `${customization.borderRadius}px`,
background: customization.isDarkMode
? `linear-gradient(to top, #303030, #212121)`
: `linear-gradient(to top, #f6f3fb, #f2f8fc)`,
mb: 1
}}
>
<CardContent>
<Stack
sx={{
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%'
}}
flexDirection='row'
>
<Box sx={{ height: 'auto', pr: 1 }}>
<img
style={{
objectFit: 'cover',
height: '35px',
width: 'auto'
}}
src={nextAgentGIF}
alt='agentPNG'
/>
</Box>
<div>{agent.nextAgent}</div>
</Stack>
</CardContent>
</Card>
) : (
<Card
key={index}
sx={{
border: customization.isDarkMode ? 'none' : '1px solid #e0e0e0',
borderRadius: `${customization.borderRadius}px`,
background: customization.isDarkMode
? `linear-gradient(to top, #303030, #212121)`
: `linear-gradient(to top, #f6f3fb, #f2f8fc)`,
mb: 1
}}
>
<CardContent>
<Stack
sx={{
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%'
}}
flexDirection='row'
>
<Box sx={{ height: 'auto', pr: 1 }}>
<img
style={{
objectFit: 'cover',
height: '25px',
width: 'auto'
}}
src={getAgentIcon(agent.nodeName, agent.instructions)}
alt='agentPNG'
/>
</Box>
<div>{agent.agentName}</div>
</Stack>
{agent.usedTools && agent.usedTools.length > 0 && (
<div
style={{
display: 'block',
flexDirection: 'row',
width: '100%'
}}
>
{agent.usedTools.map((tool, index) => {
return tool !== null ? (
<Chip
size='small'
key={index}
label={tool.tool}
component='a'
sx={{
mr: 1,
mt: 1,
borderColor: tool.error ? 'error.main' : undefined,
color: tool.error ? 'error.main' : undefined
}}
variant='outlined'
clickable
icon={
<IconTool
size={15}
color={
tool.error
? theme.palette.error.main
: undefined
}
/>
}
onClick={() => onSourceDialogClick(tool, 'Used Tools')}
/>
) : null
})}
</div>
)}
{agent.state && Object.keys(agent.state).length > 0 && (
<div
style={{
display: 'block',
flexDirection: 'row',
width: '100%'
}}
>
<Chip
size='small'
label={'State'}
component='a'
sx={{ mr: 1, mt: 1 }}
variant='outlined'
clickable
icon={<IconDeviceSdCard size={15} />}
onClick={() => onSourceDialogClick(agent.state, 'State')}
/>
</div>
)}
{agent.artifacts && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
flexDirection: 'row',
width: '100%',
gap: '8px'
}}
>
{agentReasoningArtifacts(agent.artifacts).map((item, index) => {
return item !== null ? (
<>{renderArtifacts(item, index, true)}</>
) : null
})}
</div>
)}
{agent.messages.length > 0 && (
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
key={Math.random()}
chatflowid={chatflowid}
isDialog={isDialog}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{agent.messages.length > 1
? agent.messages.join('\\n')
: agent.messages[0]}
</MemoizedReactMarkdown>
)}
{agent.instructions && <p>{agent.instructions}</p>}
{agent.messages.length === 0 && !agent.instructions && <p>Finished</p>}
{agent.sourceDocuments && agent.sourceDocuments.length > 0 && (
<div
style={{
display: 'block',
flexDirection: 'row',
width: '100%'
}}
>
{removeDuplicateURL(agent).map((source, index) => {
const URL =
source && source.metadata && source.metadata.source
? isValidURL(source.metadata.source)
: undefined
return (
<Chip
size='small'
key={index}
label={getLabel(URL, source) || ''}
component='a'
sx={{ mr: 1, mb: 1 }}
variant='outlined'
clickable
onClick={() =>
URL
? onURLClick(source.metadata.source)
: onSourceDialogClick(source)
}
/>
)
})}
</div>
)}
</CardContent>
</Card>
)
})}
{message.agentReasoning.map((agent, index) => (
<AgentReasoningCard
key={index}
agent={agent}
index={index}
customization={customization}
chatflowid={chatflowid}
isDialog={isDialog}
onSourceDialogClick={onSourceDialogClick}
renderArtifacts={renderArtifacts}
agentReasoningArtifacts={agentReasoningArtifacts}
getAgentIcon={getAgentIcon}
removeDuplicateURL={removeDuplicateURL}
isValidURL={isValidURL}
onURLClick={onURLClick}
/>
))}
</div>
)}
{message.agentFlowExecutedData &&
Array.isArray(message.agentFlowExecutedData) &&
message.agentFlowExecutedData.length > 0 && (
<AgentExecutedDataCard
status={message.agentFlowEventStatus}
execution={message.agentFlowExecutedData}
agentflowId={chatflowid}
sessionId={chatId}
/>
)}
{message.usedTools && (
<div
style={{
@@ -1959,30 +2026,7 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
</Box>
) : (
<>
{/* Messages are being rendered in Markdown format */}
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
key={Math.random()}
chatflowid={chatflowid}
isDialog={isDialog}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
<MemoizedReactMarkdown chatflowid={chatflowid} isFullWidth={isDialog}>
{message.message}
</MemoizedReactMarkdown>
</>
@@ -2061,7 +2105,8 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
{(message.action.elements || []).map((elem, index) => {
return (
<>
{elem.type === 'approve-button' && elem.label === 'Yes' ? (
{(elem.type === 'approve-button' && elem.label === 'Yes') ||
elem.type === 'agentflowv2-approve-button' ? (
<Button
sx={{
width: 'max-content',
@@ -2076,7 +2121,8 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
>
{elem.label}
</Button>
) : elem.type === 'reject-button' && elem.label === 'No' ? (
) : (elem.type === 'reject-button' && elem.label === 'No') ||
elem.type === 'agentflowv2-reject-button' ? (
<Button
sx={{
width: 'max-content',
@@ -2421,6 +2467,37 @@ export const ChatMessage = ({ open, chatflowid, isAgentCanvas, isDialog, preview
onCancel={() => setShowFeedbackContentDialog(false)}
onConfirm={submitFeedbackContent}
/>
<Dialog
maxWidth='md'
fullWidth
open={openFeedbackDialog}
onClose={() => {
setOpenFeedbackDialog(false)
setPendingActionData(null)
setFeedback('')
}}
>
<DialogTitle variant='h5'>Provide Feedback</DialogTitle>
<DialogContent>
<TextField
// eslint-disable-next-line
autoFocus
margin='dense'
label='Feedback'
fullWidth
multiline
rows={4}
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleSubmitFeedback}>Cancel</Button>
<Button onClick={handleSubmitFeedback} variant='contained'>
Submit
</Button>
</DialogActions>
</Dialog>
</div>
)
}
@@ -2433,3 +2510,5 @@ ChatMessage.propTypes = {
previews: PropTypes.array,
setPreviews: PropTypes.func
}
export default memo(ChatMessage)
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { memo, useState, useRef, useEffect, useContext } from 'react'
import { useDispatch } from 'react-redux'
import PropTypes from 'prop-types'
@@ -10,7 +10,7 @@ import { IconMessage, IconX, IconEraser, IconArrowsMaximize } from '@tabler/icon
import { StyledFab } from '@/ui-component/button/StyledFab'
import MainCard from '@/ui-component/cards/MainCard'
import Transitions from '@/ui-component/extended/Transitions'
import { ChatMessage } from './ChatMessage'
import ChatMessage from './ChatMessage'
import ChatExpandDialog from './ChatExpandDialog'
// api
@@ -19,6 +19,7 @@ import chatmessageApi from '@/api/chatmessage'
// Hooks
import useConfirm from '@/hooks/useConfirm'
import useNotifier from '@/utils/useNotifier'
import { flowContext } from '@/store/context/ReactFlowContext'
// Const
import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction } from '@/store/actions'
@@ -26,10 +27,11 @@ import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackba
// Utils
import { getLocalStorageChatflow, removeLocalStorageChatHistory } from '@/utils/genericHelper'
export const ChatPopUp = ({ chatflowid, isAgentCanvas }) => {
const ChatPopUp = ({ chatflowid, isAgentCanvas, onOpenChange }) => {
const theme = useTheme()
const { confirm } = useConfirm()
const dispatch = useDispatch()
const { clearAgentflowNodeStatus } = useContext(flowContext)
useNotifier()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
@@ -48,10 +50,13 @@ export const ChatPopUp = ({ chatflowid, isAgentCanvas }) => {
return
}
setOpen(false)
if (onOpenChange) onOpenChange(false)
}
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen)
const newOpenState = !open
setOpen(newOpenState)
if (onOpenChange) onOpenChange(newOpenState)
}
const expandChat = () => {
@@ -69,6 +74,7 @@ export const ChatPopUp = ({ chatflowid, isAgentCanvas }) => {
open: false
}
setExpandDialogProps(props)
clearAgentflowNodeStatus()
setTimeout(() => {
const resetProps = {
...expandDialogProps,
@@ -127,6 +133,7 @@ export const ChatPopUp = ({ chatflowid, isAgentCanvas }) => {
useEffect(() => {
if (prevOpen.current === true && open === false) {
anchorRef.current.focus()
if (onOpenChange) onOpenChange(false)
}
prevOpen.current = open
@@ -146,6 +153,7 @@ export const ChatPopUp = ({ chatflowid, isAgentCanvas }) => {
>
{open ? <IconX /> : <IconMessage />}
</StyledFab>
{open && (
<StyledFab
sx={{ position: 'absolute', right: 80, top: 20 }}
@@ -227,4 +235,10 @@ export const ChatPopUp = ({ chatflowid, isAgentCanvas }) => {
)
}
ChatPopUp.propTypes = { chatflowid: PropTypes.string, isAgentCanvas: PropTypes.bool }
ChatPopUp.propTypes = {
chatflowid: PropTypes.string,
isAgentCanvas: PropTypes.bool,
onOpenChange: PropTypes.func
}
export default memo(ChatPopUp)
@@ -0,0 +1,301 @@
import { useState, useRef, useEffect, memo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import PropTypes from 'prop-types'
import { Typography, Box, ClickAwayListener, Paper, Popper, Button } from '@mui/material'
import { useTheme, alpha, lighten, darken } from '@mui/material/styles'
import { IconCheckbox, IconMessage, IconX, IconExclamationCircle, IconChecklist } from '@tabler/icons-react'
// project import
import { StyledFab } from '@/ui-component/button/StyledFab'
import MainCard from '@/ui-component/cards/MainCard'
import Transitions from '@/ui-component/extended/Transitions'
import validate_empty from '@/assets/images/validate_empty.svg'
// api
import validationApi from '@/api/validation'
// Hooks
import useNotifier from '@/utils/useNotifier'
// Const
import { enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
import { AGENTFLOW_ICONS } from '@/store/constant'
// Utils
const ValidationPopUp = ({ chatflowid, hidden }) => {
const theme = useTheme()
const dispatch = useDispatch()
const customization = useSelector((state) => state.customization)
useNotifier()
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
const [open, setOpen] = useState(false)
const [previews, setPreviews] = useState([])
const [loading, setLoading] = useState(false)
const anchorRef = useRef(null)
const prevOpen = useRef(open)
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return
}
setOpen(false)
}
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen)
}
const validateFlow = async () => {
if (!chatflowid) return
try {
setLoading(true)
const response = await validationApi.checkValidation(chatflowid)
setPreviews(response.data)
if (response.data.length === 0) {
enqueueSnackbar({
message: 'No issues found in your flow!',
options: {
key: new Date().getTime() + Math.random(),
variant: 'success',
autoHideDuration: 3000
}
})
}
} catch (error) {
console.error(error)
enqueueSnackbar({
message: error.message || 'Failed to validate flow',
options: {
key: new Date().getTime() + Math.random(),
variant: 'error',
autoHideDuration: 3000
}
})
} finally {
setLoading(false)
}
}
useEffect(() => {
if (prevOpen.current === true && open === false) {
anchorRef.current.focus()
}
prevOpen.current = open
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, chatflowid])
const getNodeIcon = (item) => {
// Extract node name from the item
const nodeName = item.name
// Find matching icon from AGENTFLOW_ICONS
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === nodeName)
if (foundIcon) {
return (
<Box
sx={{
width: '28px',
height: '28px',
borderRadius: '4px',
backgroundColor: foundIcon.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white'
}}
>
<foundIcon.icon size={16} />
</Box>
)
}
// Default icon if no match found
return (
<Box
sx={{
width: '28px',
height: '28px',
borderRadius: '4px',
backgroundColor: item.type === 'LLM' ? '#4747d1' : '#f97316',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white'
}}
>
{item.type === 'LLM' ? <span></span> : <IconMessage size={16} />}
</Box>
)
}
return (
<>
{!hidden && (
<StyledFab
sx={{ position: 'absolute', right: 80, top: 20 }}
ref={anchorRef}
size='small'
color='teal'
aria-label='validation'
title='Validate Nodes'
onClick={handleToggle}
>
{open ? <IconX /> : <IconChecklist />}
</StyledFab>
)}
<Popper
placement='bottom-end'
open={open && !hidden}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
popperOptions={{
modifiers: [
{
name: 'offset',
options: {
offset: [80, 14]
}
}
]
}}
sx={{ zIndex: 1000 }}
>
{({ TransitionProps }) => (
<Transitions in={open} {...TransitionProps}>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MainCard
elevation={16}
border={false}
content={false}
sx={{
p: 2,
width: '400px',
maxWidth: '100%'
}}
boxShadow
shadow={theme.shadows[16]}
>
<Typography variant='h4' sx={{ mt: 1, mb: 2 }}>
Checklist ({previews.length})
</Typography>
<Box
sx={{
maxHeight: '60vh',
overflowY: 'auto',
pr: 1, // Add some padding to the right to account for scrollbar
mr: -1 // Negative margin to compensate for the padding
}}
>
{previews.length > 0 ? (
previews.map((item, index) => (
<Paper
key={index}
elevation={0}
sx={{
p: 2,
mb: 2,
backgroundColor: customization.isDarkMode
? theme.palette.background.paper
: theme.palette.background.neutral,
borderRadius: '8px',
border: `1px solid ${alpha('#FFB938', customization.isDarkMode ? 0.3 : 0.5)}`
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
{getNodeIcon(item)}
<div style={{ fontWeight: 500 }}>{item.label || item.name}</div>
</div>
<Box sx={{ mt: 2 }}></Box>
{item.issues.map((issue, issueIndex) => (
<Box
key={issueIndex}
sx={{
pt: 2,
px: 2,
pb: issueIndex === item.issues.length - 1 ? 2 : 1,
backgroundColor: customization.isDarkMode
? darken('#FFB938', 0.85)
: lighten('#FFB938', 0.9),
display: 'flex',
alignItems: 'center',
gap: 2
}}
>
<IconExclamationCircle
color='#FFB938'
size={20}
style={{
minWidth: '20px',
width: '20px',
height: '20px',
flexShrink: 0
}}
/>
<span>{issue}</span>
</Box>
))}
</Paper>
))
) : (
<Box
sx={{
p: 2,
height: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
<img
style={{ objectFit: 'cover', height: '15vh', width: 'auto' }}
src={validate_empty}
alt='validate_empty'
/>
</Box>
)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2, mb: 1 }}>
<Button
variant='contained'
color='teal'
onClick={validateFlow}
disabled={loading}
startIcon={loading ? null : <IconCheckbox size={18} />}
sx={{ color: 'white', minWidth: '120px' }}
>
{loading ? 'Validating...' : 'Validate Flow'}
</Button>
</Box>
</MainCard>
</ClickAwayListener>
</Paper>
</Transitions>
)}
</Popper>
</>
)
}
ValidationPopUp.propTypes = {
chatflowid: PropTypes.string,
hidden: PropTypes.bool
}
export default memo(ValidationPopUp)
@@ -29,6 +29,7 @@ import { initializeDefaultNodeData } from '@/utils/genericHelper'
// const
import { baseURL, REDACTED_CREDENTIAL_VALUE } from '@/store/constant'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
import keySVG from '@/assets/images/key.svg'
const AddEditCredentialDialog = ({ show, dialogProps, onCancel, onConfirm, setError }) => {
const portalElement = document.getElementById('portal')
@@ -237,6 +238,11 @@ const AddEditCredentialDialog = ({ show, dialogProps, onCancel, onConfirm, setEr
}}
alt={componentCredential.name}
src={`${baseURL}/api/v1/components-credentials-icon/${componentCredential.name}`}
onError={(e) => {
e.target.onerror = null
e.target.style.padding = '5px'
e.target.src = keySVG
}}
/>
</div>
{componentCredential.label}
@@ -9,6 +9,7 @@ import { IconSearch, IconX } from '@tabler/icons-react'
// const
import { baseURL } from '@/store/constant'
import { HIDE_CANVAS_DIALOG, SHOW_CANVAS_DIALOG } from '@/store/actions'
import keySVG from '@/assets/images/key.svg'
const CredentialListDialog = ({ show, dialogProps, onCancel, onCredentialSelected }) => {
const portalElement = document.getElementById('portal')
@@ -152,6 +153,11 @@ const CredentialListDialog = ({ show, dialogProps, onCancel, onCredentialSelecte
}}
alt={componentCredential.name}
src={`${baseURL}/api/v1/components-credentials-icon/${componentCredential.name}`}
onError={(e) => {
e.target.onerror = null
e.target.style.padding = '5px'
e.target.src = keySVG
}}
/>
</div>
<Typography>{componentCredential.label}</Typography>
@@ -42,6 +42,7 @@ import useNotifier from '@/utils/useNotifier'
// Icons
import { IconTrash, IconEdit, IconX, IconPlus } from '@tabler/icons-react'
import CredentialEmptySVG from '@/assets/images/credential_empty.svg'
import keySVG from '@/assets/images/key.svg'
// const
import { baseURL } from '@/store/constant'
@@ -233,6 +234,7 @@ const Credentials = () => {
search={true}
searchPlaceholder='Search Credentials'
title='Credentials'
description='API keys, tokens, and secrets for 3rd party integrations'
>
<StyledButton
variant='contained'
@@ -346,6 +348,11 @@ const Credentials = () => {
}}
alt={credential.credentialName}
src={`${baseURL}/api/v1/components-credentials-icon/${credential.credentialName}`}
onError={(e) => {
e.target.onerror = null
e.target.style.padding = '5px'
e.target.src = keySVG
}}
/>
</Box>
{credential.name}
@@ -1,12 +1,7 @@
import { createPortal } from 'react-dom'
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import rehypeMathjax from 'rehype-mathjax'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { MemoizedReactMarkdown } from '@/ui-component/markdown/MemoizedReactMarkdown'
import { CodeBlock } from '@/ui-component/markdown/CodeBlock'
import { Typography, Stack, Card, Accordion, AccordionSummary, AccordionDetails, Dialog, DialogContent, DialogTitle } from '@mui/material'
import { TableViewOnly } from '@/ui-component/table/Table'
import documentstoreApi from '@/api/documentstore'
@@ -308,29 +303,7 @@ curl -X POST http://localhost:3000/api/v1/document-store/upsert/${dialogProps.st
{dialogProps.title}
</DialogTitle>
<DialogContent>
<MemoizedReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeMathjax, rehypeRaw]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline ? (
<CodeBlock
isDialog={true}
language={(match && match[1]) || ''}
value={String(children).replace(/\n$/, '')}
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}}
>
{values}
</MemoizedReactMarkdown>
<MemoizedReactMarkdown>{values}</MemoizedReactMarkdown>
<Typography sx={{ mt: 3, mb: 1 }}>You can override existing configurations:</Typography>

Some files were not shown because too many files have changed in this diff Show More