diff --git a/.gitignore b/.gitignore index 4f4a2cf85fe..4acfb57966e 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,5 @@ apps/*/ # Claude - session/user specific files .claude/plans/ .claude/settings.local.json -.claude/agent-memory/* \ No newline at end of file +.claude/agent-memory/* +.claude/launch.json \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1893738636f..980e1d9cc64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,7 @@ Flowise support different environment variables to configure your instance. You | PORT | The HTTP port Flowise runs on | Number | 3000 | | CORS_ALLOW_CREDENTIALS | Enables CORS `Access-Control-Allow-Credentials` when `true` | Boolean | false | | CORS_ORIGINS | The allowed origins for all cross-origin HTTP calls | String | | +| MCP_CORS_ORIGINS | The allowed origins for MCP endpoint cross-origin calls. If unset, only non-browser (no Origin header) requests are allowed. Set to `*` to allow all origins. | String | | | IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | | | FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb | | DEBUG | Print logs from components | Boolean | | diff --git a/docker/.env.example b/docker/.env.example index e1832e6350f..21268f2315d 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -76,6 +76,7 @@ PORT=3000 # NUMBER_OF_PROXIES= 1 # CORS_ALLOW_CREDENTIALS=false # CORS_ORIGINS=* +# MCP_CORS_ORIGINS=* # IFRAME_ORIGINS=* # FLOWISE_FILE_SIZE_LIMIT=50mb # SHOW_COMMUNITY_NODES=true diff --git a/docker/docker-compose-queue-prebuilt.yml b/docker/docker-compose-queue-prebuilt.yml index 36f07739798..f54d29ae788 100644 --- a/docker/docker-compose-queue-prebuilt.yml +++ b/docker/docker-compose-queue-prebuilt.yml @@ -76,6 +76,7 @@ services: # SETTINGS - NUMBER_OF_PROXIES=${NUMBER_OF_PROXIES} - CORS_ORIGINS=${CORS_ORIGINS} + - MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS} - IFRAME_ORIGINS=${IFRAME_ORIGINS} - FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT} - SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index cb7d870dae8..5db97dcf0d4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -61,6 +61,7 @@ services: # SETTINGS - NUMBER_OF_PROXIES=${NUMBER_OF_PROXIES} - CORS_ORIGINS=${CORS_ORIGINS} + - MCP_CORS_ORIGINS=${MCP_CORS_ORIGINS} - IFRAME_ORIGINS=${IFRAME_ORIGINS} - FLOWISE_FILE_SIZE_LIMIT=${FLOWISE_FILE_SIZE_LIMIT} - SHOW_COMMUNITY_NODES=${SHOW_COMMUNITY_NODES} diff --git a/package.json b/package.json index 558f027d72e..14c3b1937c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flowise", - "version": "3.1.1", + "version": "3.1.2", "private": true, "homepage": "https://flowiseai.com", "workspaces": [ @@ -25,8 +25,8 @@ "user:default": "cd packages/server/bin && ./run user", "test": "turbo run test", "test:coverage": "turbo run test:coverage", - "clean": "pnpm --filter \"./packages/**\" clean", - "nuke": "pnpm --filter \"./packages/**\" nuke && rimraf node_modules .turbo", + "clean": "pnpm -r clean", + "nuke": "pnpm -r nuke && rimraf node_modules .turbo", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "lint": "eslint \"**/*.{js,jsx,ts,tsx,json,md}\"", "lint-fix": "pnpm lint --fix", diff --git a/packages/agentflow/examples/src/App.tsx b/packages/agentflow/examples/src/App.tsx index 834e3afac93..9dac1d2fa5d 100644 --- a/packages/agentflow/examples/src/App.tsx +++ b/packages/agentflow/examples/src/App.tsx @@ -15,7 +15,8 @@ import { DarkModeExampleProps, FilteredComponentsExampleProps, MultiNodeFlowProps, - StatusIndicatorsExampleProps + StatusIndicatorsExampleProps, + ValidationActionsExampleProps } from './demos' import { PropsDisplay } from './PropsDisplay' @@ -81,6 +82,13 @@ const examples: Array<{ description: 'Restrict available nodes with presets', props: FilteredComponentsExampleProps, component: lazy(() => import('./demos/FilteredComponentsExample').then((m) => ({ default: m.FilteredComponentsExample }))) + }, + { + id: 'canvas-actions', + name: 'Canvas Actions', + description: 'Custom FABs alongside the validation button via canvasActions', + props: ValidationActionsExampleProps, + component: lazy(() => import('./demos/ValidationActionsExample').then((m) => ({ default: m.ValidationActionsExample }))) } ] diff --git a/packages/agentflow/examples/src/demos/CustomUIExample.tsx b/packages/agentflow/examples/src/demos/CustomUIExample.tsx index 5b66ec1bb32..130a5203b21 100644 --- a/packages/agentflow/examples/src/demos/CustomUIExample.tsx +++ b/packages/agentflow/examples/src/demos/CustomUIExample.tsx @@ -12,26 +12,6 @@ import { Agentflow } from '@flowiseai/agentflow' import { apiBaseUrl, token } from '../config' -const initialFlow: FlowData = { - nodes: [ - { - id: 'startAgentflow_0', - type: 'agentflowNode', - position: { x: 300, y: 200 }, - data: { - id: 'startAgentflow_0', - name: 'startAgentflow', - label: 'Start', - color: '#7EE787', - hideInput: true, - outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }] - } - } - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 } -} - // Custom header component function CustomHeader({ flowName, isDirty, onSave, onExport, onValidate }: HeaderRenderProps) { const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | null>(null) @@ -259,11 +239,11 @@ export function CustomUIExample() { ref={agentflowRef} apiBaseUrl={apiBaseUrl} token={token ?? undefined} - initialFlow={initialFlow} renderHeader={(props: HeaderRenderProps) => } renderNodePalette={(props: PaletteRenderProps) => } showDefaultHeader={false} showDefaultPalette={false} + enableGenerator={false} onSave={(flow: FlowData) => { console.log('Saving flow:', flow) alert('Flow saved! Check console.') @@ -277,10 +257,10 @@ export function CustomUIExample() { export const CustomUIExampleProps = { apiBaseUrl: '{from environment variables}', token: '{from environment variables}', - initialFlow: 'FlowData', renderHeader: '(props: HeaderRenderProps) => ReactNode', renderNodePalette: '(props: PaletteRenderProps) => ReactNode', showDefaultHeader: false, showDefaultPalette: false, + enableGenerator: false, onSave: '(flow: FlowData) => void' } diff --git a/packages/agentflow/examples/src/demos/ValidationActionsExample.tsx b/packages/agentflow/examples/src/demos/ValidationActionsExample.tsx new file mode 100644 index 00000000000..c57034d7d51 --- /dev/null +++ b/packages/agentflow/examples/src/demos/ValidationActionsExample.tsx @@ -0,0 +1,71 @@ +/** + * Canvas Actions Example + * + * Demonstrates how to add custom FAB buttons alongside the built-in validation + * button in the top-right canvas overlay using the `canvasActions` prop. + * + * Mirrors the legacy v2 pattern where a chat FAB and validation FAB sit side-by-side. + */ + +import { useState } from 'react' + +import { Agentflow } from '@flowiseai/agentflow' +import { Box, Dialog, DialogContent, DialogTitle, Fab, IconButton, Typography } from '@mui/material' +import { IconMessage, IconX } from '@tabler/icons-react' + +import { apiBaseUrl, token } from '../config' + +function ChatFab() { + const [open, setOpen] = useState(false) + + return ( + <> + setOpen(true)} + sx={{ + color: 'white', + backgroundColor: 'secondary.main', + '&:hover': { + backgroundColor: 'secondary.main', + backgroundImage: 'linear-gradient(rgb(0 0 0/10%) 0 0)' + } + }} + > + + + + setOpen(false)} maxWidth='sm' fullWidth> + + Chat + setOpen(false)}> + + + + + + + Your chat UI goes here. Full control — bring any component. + + + + + + ) +} + +export function ValidationActionsExample() { + return ( +
+ } /> +
+ ) +} + +export const ValidationActionsExampleProps = { + apiBaseUrl: '{from environment variables}', + token: '{from environment variables}', + canvasActions: '' +} diff --git a/packages/agentflow/examples/src/demos/index.ts b/packages/agentflow/examples/src/demos/index.ts index 5a71e28913f..f5fa5d83e39 100644 --- a/packages/agentflow/examples/src/demos/index.ts +++ b/packages/agentflow/examples/src/demos/index.ts @@ -6,3 +6,4 @@ export * from './DarkModeExample' export * from './FilteredComponentsExample' export * from './MultiNodeFlow' export * from './StatusIndicatorsExample' +export * from './ValidationActionsExample' diff --git a/packages/agentflow/package.json b/packages/agentflow/package.json index 5fbe0515d7a..5360f3e8a9a 100644 --- a/packages/agentflow/package.json +++ b/packages/agentflow/package.json @@ -1,6 +1,6 @@ { "name": "@flowiseai/agentflow", - "version": "0.0.0-dev.10", + "version": "0.0.0-dev.12", "description": "Embeddable React component for building and visualizing AI agent workflows", "license": "Apache-2.0", "repository": { @@ -55,7 +55,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "nuke": "rimraf dist node_modules .turbo", + "nuke": "pnpm clean && rimraf node_modules .turbo", "prepublishOnly": "npm run clean && npm run build" }, "peerDependencies": { diff --git a/packages/agentflow/src/Agentflow.test.tsx b/packages/agentflow/src/Agentflow.test.tsx index 222b5e37ea3..542ee89e526 100644 --- a/packages/agentflow/src/Agentflow.test.tsx +++ b/packages/agentflow/src/Agentflow.test.tsx @@ -6,14 +6,17 @@ import { createRef } from 'react' import { fireEvent, render, waitFor } from '@testing-library/react' +import mockAxios from 'axios' -import type { AgentFlowInstance, FlowData } from './core/types' +import type { AgentFlowInstance, AgentflowProps, FlowData } from './core/types' import { Agentflow } from './Agentflow' // Mock external dependencies - implementations in __mocks__/ jest.mock('reactflow') jest.mock('axios') +const mockGet = mockAxios.get as jest.Mock + // Mock GenerateFlowDialog to expose callbacks for testing jest.mock('./features/generator', () => ({ GenerateFlowDialog: ({ @@ -425,6 +428,30 @@ describe('Agentflow Component', () => { }) }) + describe('Canvas Actions', () => { + it('should render canvasActions content in the canvas', async () => { + const { getByTestId } = render( + My Button} /> + ) + + await waitFor(() => { + expect(getByTestId('custom-action')).toBeInTheDocument() + }) + }) + + it('should not render canvasActions in read-only mode', async () => { + const { container, queryByTestId } = render( + My Button} /> + ) + + await waitFor(() => { + expect(container.querySelector('.agentflow-container')).toBeInTheDocument() + }) + + expect(queryByTestId('custom-action')).not.toBeInTheDocument() + }) + }) + describe('Imperative Ref', () => { it('should expose agentflow instance via ref', async () => { const ref = createRef() @@ -482,4 +509,106 @@ describe('Agentflow Component', () => { expect(document.getElementById('agentflow-css-variables')).not.toBeInTheDocument() }) }) + + describe('Auto Start Node Initialization', () => { + const startNodeSchema = { + name: 'startAgentflow', + label: 'Start', + type: 'Start', + category: 'Agent Flows', + description: 'Start node', + baseClasses: ['Start'], + inputs: [], + outputs: [], + version: 1 + } + + beforeEach(() => { + // Mock API to return a startAgentflow node definition + mockGet.mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/nodes')) { + return Promise.resolve({ data: [startNodeSchema] }) + } + return Promise.resolve({ data: [] }) + }) + }) + + afterEach(() => { + // Restore default mock (empty array) + mockGet.mockImplementation(() => Promise.resolve({ data: [] })) + }) + + /** Helper: render without initialFlow and assert Start node via ref */ + const renderAndGetNodes = async (initialFlow?: FlowData | null) => { + const ref = createRef() + const props: Record = { apiBaseUrl: 'https://example.com', ref } + if (initialFlow !== undefined) props.initialFlow = initialFlow + + render() + + await waitFor(() => { + expect(ref.current).toBeDefined() + const flow = ref.current!.getFlow() + expect(flow.nodes.length).toBeGreaterThan(0) + }) + return ref.current!.getFlow().nodes + } + + it('should auto-add Start node when initialFlow is undefined', async () => { + const nodes = await renderAndGetNodes() + expect(nodes).toHaveLength(1) + expect(nodes[0].id).toBe('startAgentflow_0') + expect(nodes[0].data.name).toBe('startAgentflow') + expect(nodes[0].data.label).toBe('Start') + }) + + it('should auto-add Start node when initialFlow is null', async () => { + const nodes = await renderAndGetNodes(null) + expect(nodes).toHaveLength(1) + expect(nodes[0].data.name).toBe('startAgentflow') + }) + + it('should auto-add Start node when initialFlow has empty nodes', async () => { + const emptyFlow: FlowData = { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } + const nodes = await renderAndGetNodes(emptyFlow) + expect(nodes).toHaveLength(1) + expect(nodes[0].data.name).toBe('startAgentflow') + }) + + it('should auto-add Start node when initialFlow is empty object', async () => { + const nodes = await renderAndGetNodes({} as FlowData) + expect(nodes).toHaveLength(1) + expect(nodes[0].data.name).toBe('startAgentflow') + }) + + it('should handle initialFlow with unrelated fields without errors', async () => { + const illegalFlow = { foo: 'bar', baz: 123 } as unknown as FlowData + const nodes = await renderAndGetNodes(illegalFlow) + + expect(nodes).toHaveLength(1) + expect(nodes[0].data.name).toBe('startAgentflow') + }) + + it('should handle initialFlow with wrong types for nodes/edges without crashing', async () => { + const illegalFlow = { nodes: 'not-an-array', edges: 42 } as unknown as FlowData + const nodes = await renderAndGetNodes(illegalFlow) + + // Non-array nodes/edges are safely ignored, auto-init adds Start node + expect(nodes).toHaveLength(1) + expect(nodes[0].data.name).toBe('startAgentflow') + }) + + it('should not auto-add Start node when initialFlow has nodes', async () => { + const ref = createRef() + render() + + await waitFor(() => { + expect(ref.current).toBeDefined() + }) + const nodes = ref.current!.getFlow().nodes + // Should only have the one node from mockFlow, no duplicate Start added + const startNodes = nodes.filter((n) => n.data.name === 'startAgentflow') + expect(startNodes).toHaveLength(1) + }) + }) }) diff --git a/packages/agentflow/src/Agentflow.tsx b/packages/agentflow/src/Agentflow.tsx index dea422e32e0..df1e71a2243 100644 --- a/packages/agentflow/src/Agentflow.tsx +++ b/packages/agentflow/src/Agentflow.tsx @@ -6,6 +6,7 @@ import { IconSparkles } from '@tabler/icons-react' import { tokens } from './core/theme' import type { AgentFlowInstance, AgentflowProps, FlowData, FlowDataCallback, FlowEdge, FlowNode } from './core/types' +import { initNode, resolveNodeType } from './core/utils' import { applyValidationErrorsToNodes, validateFlow } from './core/validation' import { AgentflowHeader, @@ -40,6 +41,7 @@ function AgentflowCanvas({ showDefaultHeader = true, enableGenerator = true, showDefaultPalette = true, + canvasActions, renderHeader, renderNodePalette }: { @@ -51,6 +53,7 @@ function AgentflowCanvas({ showDefaultHeader?: boolean showDefaultPalette?: boolean enableGenerator?: boolean + canvasActions?: AgentflowProps['canvasActions'] renderHeader?: AgentflowProps['renderHeader'] renderNodePalette?: AgentflowProps['renderNodePalette'] }) { @@ -80,8 +83,10 @@ function AgentflowCanvas({ } }, [isDarkMode]) - const [nodes, setLocalNodes, onNodesChange] = useNodesState(initialFlow?.nodes || []) - const [edges, setLocalEdges, onEdgesChange] = useEdgesState(initialFlow?.edges || []) + const safeInitialNodes = Array.isArray(initialFlow?.nodes) ? initialFlow.nodes : [] + const safeInitialEdges = Array.isArray(initialFlow?.edges) ? initialFlow.edges : [] + const [nodes, setLocalNodes, onNodesChange] = useNodesState(safeInitialNodes) + const [edges, setLocalEdges, onEdgesChange] = useEdgesState(safeInitialEdges) const [showGenerateDialog, setShowGenerateDialog] = useState(false) // Constraint violation snackbar state @@ -98,6 +103,29 @@ function AgentflowCanvas({ // Load available nodes const { availableNodes } = useFlowNodes() + // Auto-add Start node when creating a new (empty) canvas. + // Only runs once: when availableNodes first loads and the canvas has no initial flow. + const hasInitialFlow = safeInitialNodes.length > 0 + const startNodeInitialized = useRef(false) + useEffect(() => { + if (hasInitialFlow || startNodeInitialized.current) return + if (availableNodes.length === 0) return + + const startNodeDef = availableNodes.find((n) => n.name === 'startAgentflow') + if (!startNodeDef) return + + startNodeInitialized.current = true + const startNodeId = 'startAgentflow_0' + const startNodeData = initNode(startNodeDef, startNodeId, true) + const startNode: FlowNode = { + id: startNodeId, + type: resolveNodeType(startNodeDef.type ?? ''), + position: { x: 100, y: 100 }, + data: { ...startNodeData, label: 'Start' } + } + setLocalNodes([startNode]) + }, [hasInitialFlow, availableNodes, setLocalNodes]) + // Register local state setters with context on mount useEffect(() => { registerLocalStateSetters(setLocalNodes, setLocalEdges) @@ -246,14 +274,17 @@ function AgentflowCanvas({ )} - {/* Validation Feedback - positioned at top right */} + {/* Canvas action buttons - positioned at top right */} {!readOnly && ( - >} - /> +
+ >} + /> + {canvasActions} +
)} (function renderHeader, renderNodePalette, showDefaultHeader = true, - showDefaultPalette = true + showDefaultPalette = true, + canvasActions } = props return ( @@ -368,6 +400,7 @@ export const Agentflow = forwardRef(function showDefaultPalette={showDefaultPalette} renderHeader={renderHeader} renderNodePalette={renderNodePalette} + canvasActions={canvasActions} /> @@ -390,6 +423,7 @@ const AgentflowCanvasWithRef = forwardRef< enableGenerator?: boolean renderHeader?: AgentflowProps['renderHeader'] renderNodePalette?: AgentflowProps['renderNodePalette'] + canvasActions?: AgentflowProps['canvasActions'] } >(function AgentflowCanvasWithRef(props, ref) { const agentflow = useAgentflow() diff --git a/packages/agentflow/src/atoms/ArrayInput.test.tsx b/packages/agentflow/src/atoms/ArrayInput.test.tsx index 81a685c37f9..e3a8d41f2ad 100644 --- a/packages/agentflow/src/atoms/ArrayInput.test.tsx +++ b/packages/agentflow/src/atoms/ArrayInput.test.tsx @@ -302,7 +302,7 @@ describe('ArrayInput', () => { expect(mockOnDataChange).toHaveBeenCalledWith({ inputParam: inputParamWithTypes, - newValue: [{ str: '', num: 0, bool: false, arr: [] }] + newValue: [{ str: '', num: '', bool: false, arr: [] }] }) }) diff --git a/packages/agentflow/src/atoms/MessagesInput.test.tsx b/packages/agentflow/src/atoms/MessagesInput.test.tsx index 2f68cc70e4c..8ae2af7fd64 100644 --- a/packages/agentflow/src/atoms/MessagesInput.test.tsx +++ b/packages/agentflow/src/atoms/MessagesInput.test.tsx @@ -136,14 +136,14 @@ describe('MessagesInput', () => { // --- Add --- - it('should add a new message with default role "user" and empty content', () => { + it('should add a new message with empty role and empty content', () => { render() fireEvent.click(screen.getByRole('button', { name: /Add Messages/i })) expect(mockOnDataChange).toHaveBeenCalledWith({ inputParam: mockInputParam, - newValue: [{ role: 'user', content: '' }] + newValue: [{ role: '', content: '' }] }) }) @@ -163,7 +163,7 @@ describe('MessagesInput', () => { inputParam: mockInputParam, newValue: [ { role: 'system', content: 'Hello' }, - { role: 'user', content: '' } + { role: '', content: '' } ] }) }) diff --git a/packages/agentflow/src/atoms/MessagesInput.tsx b/packages/agentflow/src/atoms/MessagesInput.tsx index 7c5704e6708..ca13d20b487 100644 --- a/packages/agentflow/src/atoms/MessagesInput.tsx +++ b/packages/agentflow/src/atoms/MessagesInput.tsx @@ -22,7 +22,7 @@ const MESSAGE_ROLES = [ type MessageRole = (typeof MESSAGE_ROLES)[number]['value'] export interface MessageEntry { - role: MessageRole + role: MessageRole | '' content: string } @@ -77,7 +77,7 @@ export function MessagesInput({ inputParam, data, disabled = false, variableItem ) const handleAddMessage = useCallback(() => { - const newMessage: MessageEntry = { role: 'user', content: '' } + const newMessage: MessageEntry = { role: '', content: '' } onDataChange?.({ inputParam, newValue: [...messages, newMessage] }) }, [messages, inputParam, onDataChange]) diff --git a/packages/agentflow/src/core/primitives/inputDefaults.test.ts b/packages/agentflow/src/core/primitives/inputDefaults.test.ts index 18ffbba577f..bf9d525b1d8 100644 --- a/packages/agentflow/src/core/primitives/inputDefaults.test.ts +++ b/packages/agentflow/src/core/primitives/inputDefaults.test.ts @@ -15,8 +15,8 @@ describe('getDefaultValueForType', () => { expect(getDefaultValueForType({ type: 'boolean' })).toBe(false) }) - it('returns 0 for number', () => { - expect(getDefaultValueForType({ type: 'number' })).toBe(0) + it("returns '' for number without explicit default", () => { + expect(getDefaultValueForType({ type: 'number' })).toBe('') }) it("returns '{}' for json", () => { @@ -27,24 +27,28 @@ describe('getDefaultValueForType', () => { expect(getDefaultValueForType({ type: 'array' })).toEqual([]) }) - it('returns first option name for object options', () => { + it("returns '' for options without explicit default", () => { expect( getDefaultValueForType({ type: 'options', options: [{ name: 'first' }, { name: 'second' }] }) - ).toBe('first') - }) - - it('returns first option value for string options', () => { - expect(getDefaultValueForType({ type: 'options', options: ['alpha', 'beta'] })).toBe('alpha') - }) - - it("returns '' for options with no options", () => { + ).toBe('') + expect(getDefaultValueForType({ type: 'options', options: ['alpha', 'beta'] })).toBe('') expect(getDefaultValueForType({ type: 'options' })).toBe('') expect(getDefaultValueForType({ type: 'options', options: [] })).toBe('') }) + it('returns explicit default for options when provided', () => { + expect( + getDefaultValueForType({ + type: 'options', + default: 'second', + options: [{ name: 'first' }, { name: 'second' }] + }) + ).toBe('second') + }) + it("returns '' for string", () => { expect(getDefaultValueForType({ type: 'string' })).toBe('') }) diff --git a/packages/agentflow/src/core/primitives/inputDefaults.ts b/packages/agentflow/src/core/primitives/inputDefaults.ts index 69dbcc60014..08cde0e24af 100644 --- a/packages/agentflow/src/core/primitives/inputDefaults.ts +++ b/packages/agentflow/src/core/primitives/inputDefaults.ts @@ -8,8 +8,7 @@ */ export function getDefaultValueForType({ type, - default: defaultValue, - options + default: defaultValue }: { type: string default?: unknown @@ -21,16 +20,12 @@ export function getDefaultValueForType({ case 'boolean': return false case 'number': - return 0 + return '' case 'json': return '{}' case 'array': return [] - case 'options': { - const first = options?.[0] - if (!first) return '' - return typeof first === 'string' ? first : first.name - } + case 'options': case 'string': case 'password': default: diff --git a/packages/agentflow/src/core/types/agentflow.ts b/packages/agentflow/src/core/types/agentflow.ts index 6acee224567..522b6b69be9 100644 --- a/packages/agentflow/src/core/types/agentflow.ts +++ b/packages/agentflow/src/core/types/agentflow.ts @@ -59,6 +59,21 @@ export interface AgentflowProps { /** Whether the canvas is read-only */ readOnly?: boolean + /** + * Additional buttons rendered in the top-right canvas overlay, to the right of the built-in + * validation FAB. Consumers have full control over content — pass any ReactNode (FABs, icon + * buttons, popovers, dialogs, etc.). Hidden when `readOnly` is true. + * + * @example + * // Add a chat button alongside the validation FAB (mirrors legacy v2 pattern) + * canvasActions={ + * setShowChat(true)}> + * + * + * } + */ + canvasActions?: ReactNode + /** Custom header renderer - receives save/export handlers */ renderHeader?: (props: HeaderRenderProps) => ReactNode diff --git a/packages/agentflow/src/features/canvas/components/ValidationFeedback.tsx b/packages/agentflow/src/features/canvas/components/ValidationFeedback.tsx index beebb4f0f4b..29e3b96a14f 100644 --- a/packages/agentflow/src/features/canvas/components/ValidationFeedback.tsx +++ b/packages/agentflow/src/features/canvas/components/ValidationFeedback.tsx @@ -144,7 +144,7 @@ function ValidationFeedbackComponent({ nodes, edges, availableNodes, setNodes }: return ( <> -
+
{ expect(result.current.state.nodes.find((n) => n.id === 'Node 1_0')).toBeDefined() }) }) + + describe('onFlowChange notifications', () => { + const mockOnFlowChange = jest.fn() + + beforeEach(() => { + mockOnFlowChange.mockClear() + }) + + it('should call onFlowChange when deleteNode is called', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [makeEdge('node-1', 'node-2')] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.registerOnFlowChange(mockOnFlowChange) + }) + + act(() => { + result.current.deleteNode('node-2') + }) + + expect(mockOnFlowChange).toHaveBeenCalledTimes(1) + expect(mockOnFlowChange).toHaveBeenCalledWith( + expect.objectContaining({ + nodes: expect.arrayContaining([expect.objectContaining({ id: 'node-1' })]), + edges: [] + }) + ) + // Deleted node should not be in the callback payload + const callArgs = mockOnFlowChange.mock.calls[0][0] + expect(callArgs.nodes.find((n: FlowNode) => n.id === 'node-2')).toBeUndefined() + }) + + it('should call onFlowChange when deleteEdge is called', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [makeEdge('node-1', 'node-2'), makeEdge('node-2', 'node-1')] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.registerOnFlowChange(mockOnFlowChange) + }) + + act(() => { + result.current.deleteEdge('node-1-node-2') + }) + + expect(mockOnFlowChange).toHaveBeenCalledTimes(1) + expect(mockOnFlowChange).toHaveBeenCalledWith( + expect.objectContaining({ + nodes: expect.arrayContaining([expect.objectContaining({ id: 'node-1' }), expect.objectContaining({ id: 'node-2' })]), + edges: [expect.objectContaining({ id: 'node-2-node-1' })] + }) + ) + }) + + it('should call onFlowChange when duplicateNode is called', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.registerOnFlowChange(mockOnFlowChange) + }) + + act(() => { + result.current.duplicateNode('node-1') + }) + + expect(mockOnFlowChange).toHaveBeenCalledTimes(1) + expect(mockOnFlowChange).toHaveBeenCalledWith( + expect.objectContaining({ + nodes: expect.arrayContaining([expect.objectContaining({ id: 'node-1' })]), + edges: [] + }) + ) + // Should have 2 nodes (original + duplicate) + const callArgs = mockOnFlowChange.mock.calls[0][0] + expect(callArgs.nodes).toHaveLength(2) + }) + }) }) diff --git a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx index f247931c4b9..c996844117e 100644 --- a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx +++ b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx @@ -99,8 +99,8 @@ interface AgentflowStateProviderProps { export function AgentflowStateProvider({ children, initialFlow }: AgentflowStateProviderProps) { const [state, dispatch] = useReducer(agentflowReducer, { ...initialState, - nodes: normalizeNodes(initialFlow?.nodes || []), - edges: initialFlow?.edges || [] + nodes: normalizeNodes(Array.isArray(initialFlow?.nodes) ? initialFlow.nodes : []), + edges: Array.isArray(initialFlow?.edges) ? initialFlow.edges : [] }) // Store ReactFlow local state setters in refs which are populated by AgentflowCanvas @@ -176,8 +176,14 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState const newNodes = state.nodes.filter((node) => node.id !== nodeId) const newEdges = state.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId) syncStateUpdate({ nodes: newNodes, edges: newEdges }) + + // Notify parent of flow change so the deletion is persisted + if (onFlowChangeRef.current) { + const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 } + onFlowChangeRef.current({ nodes: newNodes, edges: newEdges, viewport }) + } }, - [state.nodes, state.edges, syncStateUpdate] + [state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate] ) const duplicateNode = useCallback( @@ -227,9 +233,16 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState } } - syncStateUpdate({ nodes: [...state.nodes, newNode] }) + const newNodes = [...state.nodes, newNode] + syncStateUpdate({ nodes: newNodes }) + + // Notify parent of flow change so the duplication is persisted + if (onFlowChangeRef.current) { + const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 } + onFlowChangeRef.current({ nodes: normalizeNodes(newNodes), edges: state.edges, viewport }) + } }, - [state.nodes, syncStateUpdate] + [state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate] ) const updateNodeData = useCallback( @@ -261,8 +274,14 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState (edgeId: string) => { const newEdges = state.edges.filter((edge) => edge.id !== edgeId) syncStateUpdate({ edges: newEdges }) + + // Notify parent of flow change so the deletion is persisted + if (onFlowChangeRef.current) { + const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 } + onFlowChangeRef.current({ nodes: state.nodes, edges: newEdges, viewport }) + } }, - [state.edges, syncStateUpdate] + [state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate] ) // Dialog operations diff --git a/packages/api-documentation/package.json b/packages/api-documentation/package.json index 849a10a2972..6d2587bfbc7 100644 --- a/packages/api-documentation/package.json +++ b/packages/api-documentation/package.json @@ -5,6 +5,8 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", + "clean": "rimraf dist", + "nuke": "pnpm clean && rimraf node_modules .turbo", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "license": "SEE LICENSE IN LICENSE.md", diff --git a/packages/components/models.json b/packages/components/models.json index 6be19c3157f..d257799703b 100644 --- a/packages/components/models.json +++ b/packages/components/models.json @@ -815,6 +815,27 @@ } ] }, + { + "name": "chatBaiduWenxin", + "models": [ + { + "label": "ernie-4.5-8k-preview", + "name": "ernie-4.5-8k-preview" + }, + { + "label": "ernie-4.0-8k", + "name": "ernie-4.0-8k" + }, + { + "label": "ernie-3.5-8k-preview", + "name": "ernie-3.5-8k-preview" + }, + { + "label": "ernie-speed-128k", + "name": "ernie-speed-128k" + } + ] + }, { "name": "chatAlibabaTongyi", "models": [ @@ -2183,6 +2204,27 @@ } ] }, + { + "name": "baiduQianfanEmbeddings", + "models": [ + { + "label": "Embedding-V1", + "name": "Embedding-V1" + }, + { + "label": "bge-large-zh", + "name": "bge-large-zh" + }, + { + "label": "bge-large-en", + "name": "bge-large-en" + }, + { + "label": "tao-8k", + "name": "tao-8k" + } + ] + }, { "name": "mistralAIEmbeddings", "models": [ diff --git a/packages/components/nodes/agentflow/Agent/Agent.ts b/packages/components/nodes/agentflow/Agent/Agent.ts index 63730c2d15b..6bc1d9cd09c 100644 --- a/packages/components/nodes/agentflow/Agent/Agent.ts +++ b/packages/components/nodes/agentflow/Agent/Agent.ts @@ -30,6 +30,7 @@ import { getUniqueImageMessages, processMessagesWithImages, revertBase64ImagesToFileRefs, + normalizeMessagesForStorage, replaceInlineDataWithFileReferences, updateFlowState } from '../utils' @@ -1469,7 +1470,8 @@ class Agent_Agentflow implements INode { * This is to avoid storing the actual base64 data into database */ const messagesToStore = messages.filter((msg: any) => !msg._isTemporaryImageMessage) - const messagesWithFileReferences = revertBase64ImagesToFileRefs(messagesToStore) + const normalizedMessagesToStore = normalizeMessagesForStorage(messagesToStore) + const messagesWithFileReferences = revertBase64ImagesToFileRefs(normalizedMessagesToStore) // Only add to runtime chat history if this is the first node const inputMessages = [] @@ -2233,13 +2235,7 @@ class Agent_Agentflow implements INode { } // Add LLM response with tool calls to messages - messages.push({ - id: response.id, - role: 'assistant', - content: response.content, - tool_calls: response.tool_calls, - usage_metadata: response.usage_metadata - }) + messages.push(response) // Process each tool call for (let i = 0; i < response.tool_calls.length; i++) { @@ -2620,13 +2616,7 @@ class Agent_Agentflow implements INode { } // Add LLM response with tool calls to messages - messages.push({ - id: response.id, - role: 'assistant', - content: response.content, - tool_calls: response.tool_calls, - usage_metadata: response.usage_metadata - }) + messages.push(response) // Process each tool call for (let i = 0; i < response.tool_calls.length; i++) { diff --git a/packages/components/nodes/agentflow/SmartAgent/SmartAgent.ts b/packages/components/nodes/agentflow/SmartAgent/SmartAgent.ts new file mode 100644 index 00000000000..39f6f75152d --- /dev/null +++ b/packages/components/nodes/agentflow/SmartAgent/SmartAgent.ts @@ -0,0 +1,2993 @@ +import { BaseChatModel } from '@langchain/core/language_models/chat_models' +import { + ICommonObject, + IDatabaseEntity, + IHumanInput, + IMessage, + INode, + INodeData, + INodeOptionsValue, + INodeParams, + IServerSideEventStreamer, + IUsedTool +} from '../../../src/Interface' +import { ContentBlock } from 'langchain' +import { AIMessageChunk, BaseMessageLike } from '@langchain/core/messages' +import { AnalyticHandler } from '../../../src/handler' +import { DEFAULT_SUMMARIZER_TEMPLATE } from '../prompt' +import { ILLMMessage, IResponseMetadata } from '../Interface.Agentflow' +import { DynamicStructuredTool, Tool } from '@langchain/core/tools' +import { ARTIFACTS_PREFIX, SOURCE_DOCUMENTS_PREFIX, TOOL_ARGS_PREFIX } from '../../../src/agents' +import { flatten } from 'lodash' +import zodToJsonSchema from 'zod-to-json-schema' +import { z } from 'zod' +import { PlanningTool, Todo } from './planning/PlanningTool' +import { buildSystemPrompt } from './context/SystemPromptBuilder' +import { getErrorMessage } from '../../../src/error' +import { DataSource } from 'typeorm' +import { randomBytes } from 'crypto' +import { + addImageArtifactsToMessages, + extractArtifactsFromResponse, + getPastChatHistoryImageMessages, + getUniqueImageMessages, + processMessagesWithImages, + revertBase64ImagesToFileRefs, + normalizeMessagesForStorage, + replaceInlineDataWithFileReferences, + updateFlowState +} from '../utils' +import { + convertMultiOptionsToStringArray, + processTemplateVariables, + configureStructuredOutput, + extractResponseContent +} from '../../../src/utils' +import { sanitizeFileName } from '../../../src/validator' +import { getModelConfigByModelName, MODEL_TYPE } from '../../../src/modelLoader' + +interface ITool { + agentSelectedTool: string + agentSelectedToolConfig: ICommonObject + agentSelectedToolRequiresHumanInput: boolean +} + +interface IKnowledgeBase { + documentStore: string + docStoreDescription: string + returnSourceDocuments: boolean +} + +interface IKnowledgeBaseVSEmbeddings { + vectorStore: string + vectorStoreConfig: ICommonObject + embeddingModel: string + embeddingModelConfig: ICommonObject + knowledgeName: string + knowledgeDescription: string + returnSourceDocuments: boolean +} + +interface ISimpliefiedTool { + name: string + description: string + schema: any + toolNode: { + label: string + name: string + } +} + +/** + * Sanitizes a string to be used as a tool name. + * Restricts to ASCII characters [a-z0-9_-] for LLM API compatibility (OpenAI, Anthropic, Gemini). + * Non-ASCII titles (Korean, Chinese, Japanese, etc.) will use auto-generated fallback names. + * This prevents 'Invalid tools[0].function.name: empty string' errors. + */ +const sanitizeToolName = (name: string): string => { + const sanitized = name + .toLowerCase() + .replace(/ /g, '_') + .replace(/[^a-z0-9_-]/g, '') // ASCII only for LLM API compatibility + + // If the result is empty (e.g., non-ASCII only input), generate a unique fallback name + if (!sanitized) { + return `tool_${Date.now()}_${randomBytes(4).toString('hex').slice(0, 5)}` + } + + // Enforce 64 character limit common for tool names + return sanitized.slice(0, 64) +} + +class SmartAgent_Agentflow implements INode { + label: string + name: string + version: number + description: string + type: string + icon: string + category: string + color: string + baseClasses: string[] + documentation?: string + credential: INodeParams + inputs: INodeParams[] + + constructor() { + this.label = 'Smart Agent' + this.name = 'smartAgentAgentflow' + this.version = 1.0 + this.type = 'Smart Agent' + this.category = 'Agent Flows' + this.description = 'Built in harness for building smart agents' + this.color = '#AB47BC' + this.baseClasses = [this.type] + this.inputs = [ + { + label: 'Model', + name: 'agentModel', + type: 'asyncOptions', + loadMethod: 'listModels', + loadConfig: true + }, + { + label: 'Messages', + name: 'agentMessages', + type: 'array', + optional: true, + acceptVariable: true, + array: [ + { + label: 'Role', + name: 'role', + type: 'options', + options: [ + { + label: 'System', + name: 'system' + }, + { + label: 'Assistant', + name: 'assistant' + }, + { + label: 'Developer', + name: 'developer' + }, + { + label: 'User', + name: 'user' + } + ] + }, + { + label: 'Content', + name: 'content', + type: 'string', + acceptVariable: true, + generateInstruction: true, + rows: 4 + } + ] + }, + { + label: 'OpenAI Built-in Tools', + name: 'agentToolsBuiltInOpenAI', + type: 'multiOptions', + optional: true, + options: [ + { + label: 'Web Search', + name: 'web_search_preview', + description: 'Search the web for the latest information' + }, + { + label: 'Code Interpreter', + name: 'code_interpreter', + description: 'Write and run Python code in a sandboxed environment' + }, + { + label: 'Image Generation', + name: 'image_generation', + description: 'Generate images based on a text prompt' + } + ], + show: { + agentModel: 'chatOpenAI' + } + }, + { + label: 'Gemini Built-in Tools', + name: 'agentToolsBuiltInGemini', + type: 'multiOptions', + optional: true, + options: [ + { + label: 'URL Context', + name: 'urlContext', + description: 'Extract content from given URLs' + }, + { + label: 'Google Search', + name: 'googleSearch', + description: 'Search real-time web content' + }, + { + label: 'Code Execution', + name: 'codeExecution', + description: 'Write and run Python code in a sandboxed environment' + } + ], + show: { + agentModel: 'chatGoogleGenerativeAI' + } + }, + { + label: 'Anthropic Built-in Tools', + name: 'agentToolsBuiltInAnthropic', + type: 'multiOptions', + optional: true, + options: [ + { + label: 'Web Search', + name: 'web_search_20250305', + description: 'Search the web for the latest information' + }, + { + label: 'Web Fetch', + name: 'web_fetch_20250910', + description: 'Retrieve full content from specified web pages' + } + /* + * Not supported yet as we need to get bash_code_execution_tool_result from content: + https://docs.claude.com/en/docs/agents-and-tools/tool-use/code-execution-tool#retrieve-generated-files + { + label: 'Code Interpreter', + name: 'code_execution_20250825', + description: 'Write and run Python code in a sandboxed environment' + }*/ + ], + show: { + agentModel: 'chatAnthropic' + } + }, + { + label: 'Tools', + name: 'agentTools', + type: 'array', + optional: true, + array: [ + { + label: 'Tool', + name: 'agentSelectedTool', + type: 'asyncOptions', + loadMethod: 'listTools', + loadConfig: true + }, + { + label: 'Require Human Input', + name: 'agentSelectedToolRequiresHumanInput', + type: 'boolean', + optional: true + } + ] + }, + { + label: 'Knowledge (Document Stores)', + name: 'agentKnowledgeDocumentStores', + type: 'array', + description: 'Give your agent context about different document sources. Document stores must be upserted in advance.', + array: [ + { + label: 'Document Store', + name: 'documentStore', + type: 'asyncOptions', + loadMethod: 'listStores' + }, + { + label: 'Describe Knowledge', + name: 'docStoreDescription', + type: 'string', + generateDocStoreDescription: true, + placeholder: + 'Describe what the knowledge base is about, this is useful for the AI to know when and how to search for correct information', + rows: 4 + }, + { + label: 'Return Source Documents', + name: 'returnSourceDocuments', + type: 'boolean', + optional: true + } + ], + optional: true + }, + { + label: 'Knowledge (Vector Embeddings)', + name: 'agentKnowledgeVSEmbeddings', + type: 'array', + description: 'Give your agent context about different document sources from existing vector stores and embeddings', + array: [ + { + label: 'Vector Store', + name: 'vectorStore', + type: 'asyncOptions', + loadMethod: 'listVectorStores', + loadConfig: true + }, + { + label: 'Embedding Model', + name: 'embeddingModel', + type: 'asyncOptions', + loadMethod: 'listEmbeddings', + loadConfig: true + }, + { + label: 'Knowledge Name', + name: 'knowledgeName', + type: 'string', + placeholder: + 'A short name for the knowledge base, this is useful for the AI to know when and how to search for correct information' + }, + { + label: 'Describe Knowledge', + name: 'knowledgeDescription', + type: 'string', + placeholder: + 'Describe what the knowledge base is about, this is useful for the AI to know when and how to search for correct information', + rows: 4 + }, + { + label: 'Return Source Documents', + name: 'returnSourceDocuments', + type: 'boolean', + optional: true + } + ], + optional: true + }, + { + label: 'Enable Memory', + name: 'agentEnableMemory', + type: 'boolean', + description: 'Enable memory for the conversation thread', + default: true, + optional: true + }, + { + label: 'Memory Type', + name: 'agentMemoryType', + type: 'options', + options: [ + { + label: 'All Messages', + name: 'allMessages', + description: 'Retrieve all messages from the conversation' + }, + { + label: 'Window Size', + name: 'windowSize', + description: 'Uses a fixed window size to surface the last N messages' + }, + { + label: 'Conversation Summary', + name: 'conversationSummary', + description: 'Summarizes the whole conversation' + }, + { + label: 'Conversation Summary Buffer', + name: 'conversationSummaryBuffer', + description: 'Summarize conversations once token limit is reached. Default to 2000' + } + ], + optional: true, + default: 'allMessages', + show: { + agentEnableMemory: true + } + }, + { + label: 'Window Size', + name: 'agentMemoryWindowSize', + type: 'number', + default: '20', + description: 'Uses a fixed window size to surface the last N messages', + show: { + agentMemoryType: 'windowSize' + } + }, + { + label: 'Max Token Limit', + name: 'agentMemoryMaxTokenLimit', + type: 'number', + default: '2000', + description: 'Summarize conversations once token limit is reached. Default to 2000', + show: { + agentMemoryType: 'conversationSummaryBuffer' + } + }, + { + label: 'Input Message', + name: 'agentUserMessage', + type: 'string', + description: 'Add an input message as user message at the end of the conversation', + rows: 4, + optional: true, + acceptVariable: true, + show: { + agentEnableMemory: true + } + }, + { + label: 'Return Response As', + name: 'agentReturnResponseAs', + type: 'options', + options: [ + { + label: 'User Message', + name: 'userMessage' + }, + { + label: 'Assistant Message', + name: 'assistantMessage' + } + ], + default: 'userMessage' + }, + { + label: 'JSON Structured Output', + name: 'agentStructuredOutput', + description: 'Instruct the Agent to give output in a JSON structured schema', + type: 'array', + optional: true, + acceptVariable: true, + array: [ + { + label: 'Key', + name: 'key', + type: 'string' + }, + { + label: 'Type', + name: 'type', + type: 'options', + options: [ + { + label: 'String', + name: 'string' + }, + { + label: 'String Array', + name: 'stringArray' + }, + { + label: 'Number', + name: 'number' + }, + { + label: 'Boolean', + name: 'boolean' + }, + { + label: 'Enum', + name: 'enum' + }, + { + label: 'JSON Array', + name: 'jsonArray' + } + ] + }, + { + label: 'Enum Values', + name: 'enumValues', + type: 'string', + placeholder: 'value1, value2, value3', + description: 'Enum values. Separated by comma', + optional: true, + show: { + 'agentStructuredOutput[$index].type': 'enum' + } + }, + { + label: 'JSON Schema', + name: 'jsonSchema', + type: 'code', + placeholder: `{ + "answer": { + "type": "string", + "description": "Value of the answer" + }, + "reason": { + "type": "string", + "description": "Reason for the answer" + }, + "optional": { + "type": "boolean" + }, + "count": { + "type": "number" + }, + "children": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Value of the children's answer" + } + } + } + } +}`, + description: 'JSON schema for the structured output', + optional: true, + hideCodeExecute: true, + show: { + 'agentStructuredOutput[$index].type': 'jsonArray' + } + }, + { + label: 'Description', + name: 'description', + type: 'string', + placeholder: 'Description of the key' + } + ] + }, + { + label: 'Update Flow State', + name: 'agentUpdateState', + description: 'Update runtime state during the execution of the workflow', + type: 'array', + optional: true, + acceptVariable: true, + array: [ + { + label: 'Key', + name: 'key', + type: 'asyncOptions', + loadMethod: 'listRuntimeStateKeys' + }, + { + label: 'Value', + name: 'value', + type: 'string', + acceptVariable: true, + acceptNodeOutputAsVariable: true + } + ] + } + ] + } + + //@ts-ignore + loadMethods = { + async listModels(_: INodeData, options: ICommonObject): Promise { + const componentNodes = options.componentNodes as { + [key: string]: INode + } + + const returnOptions: INodeOptionsValue[] = [] + for (const nodeName in componentNodes) { + const componentNode = componentNodes[nodeName] + if (componentNode.category === 'Chat Models') { + if (componentNode.tags?.includes('LlamaIndex')) { + continue + } + returnOptions.push({ + label: componentNode.label, + name: nodeName, + imageSrc: componentNode.icon + }) + } + } + return returnOptions + }, + async listEmbeddings(_: INodeData, options: ICommonObject): Promise { + const componentNodes = options.componentNodes as { + [key: string]: INode + } + + const returnOptions: INodeOptionsValue[] = [] + for (const nodeName in componentNodes) { + const componentNode = componentNodes[nodeName] + if (componentNode.category === 'Embeddings') { + if (componentNode.tags?.includes('LlamaIndex')) { + continue + } + returnOptions.push({ + label: componentNode.label, + name: nodeName, + imageSrc: componentNode.icon + }) + } + } + return returnOptions + }, + async listTools(_: INodeData, options: ICommonObject): Promise { + const componentNodes = options.componentNodes as { + [key: string]: INode + } + + const removeTools = ['chainTool', 'retrieverTool', 'webBrowser'] + + const returnOptions: INodeOptionsValue[] = [] + for (const nodeName in componentNodes) { + const componentNode = componentNodes[nodeName] + if (componentNode.category === 'Tools' || componentNode.category === 'Tools (MCP)') { + if (componentNode.tags?.includes('LlamaIndex')) { + continue + } + if (removeTools.includes(nodeName)) { + continue + } + returnOptions.push({ + label: componentNode.label, + name: nodeName, + imageSrc: componentNode.icon + }) + } + } + return returnOptions + }, + async listRuntimeStateKeys(_: INodeData, options: ICommonObject): Promise { + const previousNodes = options.previousNodes as ICommonObject[] + const startAgentflowNode = previousNodes.find((node) => node.name === 'startAgentflow') + const state = startAgentflowNode?.inputs?.startState as ICommonObject[] + return state.map((item) => ({ label: item.key, name: item.key })) + }, + async listStores(_: INodeData, options: ICommonObject): Promise { + const returnData: INodeOptionsValue[] = [] + + const appDataSource = options.appDataSource as DataSource + const databaseEntities = options.databaseEntities as IDatabaseEntity + + if (appDataSource === undefined || !appDataSource) { + return returnData + } + + const searchOptions = options.searchOptions || {} + const stores = await appDataSource.getRepository(databaseEntities['DocumentStore']).findBy(searchOptions) + for (const store of stores) { + if (store.status === 'UPSERTED') { + const obj = { + name: `${store.id}:${store.name}`, + label: store.name, + description: store.description + } + returnData.push(obj) + } + } + return returnData + }, + async listVectorStores(_: INodeData, options: ICommonObject): Promise { + const componentNodes = options.componentNodes as { + [key: string]: INode + } + + const returnOptions: INodeOptionsValue[] = [] + for (const nodeName in componentNodes) { + const componentNode = componentNodes[nodeName] + if (componentNode.category === 'Vector Stores') { + if (componentNode.tags?.includes('LlamaIndex')) { + continue + } + returnOptions.push({ + label: componentNode.label, + name: nodeName, + imageSrc: componentNode.icon + }) + } + } + return returnOptions + } + } + + async run(nodeData: INodeData, input: string | Record, options: ICommonObject): Promise { + let llmIds: ICommonObject | undefined + let analyticHandlers = options.analyticHandlers as AnalyticHandler + + try { + const abortController = options.abortController as AbortController + + // Extract input parameters + const model = nodeData.inputs?.agentModel as string + const modelConfig = nodeData.inputs?.agentModelConfig as ICommonObject + if (!model) { + throw new Error('Model is required') + } + const modelName = modelConfig?.model ?? modelConfig?.modelName + + // Extract tools + const tools = nodeData.inputs?.agentTools as ITool[] + + const toolsInstance: Tool[] = [] + for (const tool of tools) { + const toolConfig = tool.agentSelectedToolConfig + const nodeInstanceFilePath = options.componentNodes[tool.agentSelectedTool].filePath as string + const nodeModule = await import(nodeInstanceFilePath) + const newToolNodeInstance = new nodeModule.nodeClass() + const newNodeData = { + ...nodeData, + credential: toolConfig['FLOWISE_CREDENTIAL_ID'], + inputs: { + ...nodeData.inputs, + ...toolConfig + } + } + const toolInstance = await newToolNodeInstance.init(newNodeData, '', options) + + // toolInstance might returns a list of tools like MCP tools + if (Array.isArray(toolInstance)) { + for (const subTool of toolInstance) { + const subToolInstance = subTool as Tool + ;(subToolInstance as any).agentSelectedTool = tool.agentSelectedTool + if (tool.agentSelectedToolRequiresHumanInput) { + ;(subToolInstance as any).requiresHumanInput = true + } + toolsInstance.push(subToolInstance) + } + } else { + if (tool.agentSelectedToolRequiresHumanInput) { + toolInstance.requiresHumanInput = true + } + toolsInstance.push(toolInstance as Tool) + } + } + + const availableTools: ISimpliefiedTool[] = toolsInstance.map((tool, index) => { + const originalTool = tools[index] + let agentSelectedTool = (tool as any)?.agentSelectedTool + if (!agentSelectedTool) { + agentSelectedTool = originalTool?.agentSelectedTool + } + const componentNode = options.componentNodes[agentSelectedTool] + + const jsonSchema = zodToJsonSchema(tool.schema as any) + if (jsonSchema.$schema) { + delete jsonSchema.$schema + } + + return { + name: tool.name, + description: tool.description, + schema: jsonSchema, + toolNode: { + label: componentNode?.label || tool.name, + name: componentNode?.name || tool.name + } + } + }) + + // Extract knowledge + const knowledgeBases = nodeData.inputs?.agentKnowledgeDocumentStores as IKnowledgeBase[] + if (knowledgeBases && knowledgeBases.length > 0) { + for (const knowledgeBase of knowledgeBases) { + const nodeInstanceFilePath = options.componentNodes['retrieverTool'].filePath as string + const nodeModule = await import(nodeInstanceFilePath) + const newRetrieverToolNodeInstance = new nodeModule.nodeClass() + const [storeId, storeName] = knowledgeBase.documentStore.split(':') + + const docStoreVectorInstanceFilePath = options.componentNodes['documentStoreVS'].filePath as string + const docStoreVectorModule = await import(docStoreVectorInstanceFilePath) + const newDocStoreVectorInstance = new docStoreVectorModule.nodeClass() + const docStoreVectorInstance = await newDocStoreVectorInstance.init( + { + ...nodeData, + inputs: { + ...nodeData.inputs, + selectedStore: storeId + }, + outputs: { + output: 'retriever' + } + }, + '', + options + ) + + const newRetrieverToolNodeData = { + ...nodeData, + inputs: { + ...nodeData.inputs, + name: sanitizeToolName(storeName), + description: knowledgeBase.docStoreDescription, + retriever: docStoreVectorInstance, + returnSourceDocuments: knowledgeBase.returnSourceDocuments + } + } + const retrieverToolInstance = await newRetrieverToolNodeInstance.init(newRetrieverToolNodeData, '', options) + + toolsInstance.push(retrieverToolInstance as Tool) + + const jsonSchema = zodToJsonSchema(retrieverToolInstance.schema) + if (jsonSchema.$schema) { + delete jsonSchema.$schema + } + const componentNode = options.componentNodes['retrieverTool'] + + availableTools.push({ + name: sanitizeToolName(storeName), + description: knowledgeBase.docStoreDescription, + schema: jsonSchema, + toolNode: { + label: componentNode?.label || retrieverToolInstance.name, + name: componentNode?.name || retrieverToolInstance.name + } + }) + } + } + + const knowledgeBasesForVSEmbeddings = nodeData.inputs?.agentKnowledgeVSEmbeddings as IKnowledgeBaseVSEmbeddings[] + if (knowledgeBasesForVSEmbeddings && knowledgeBasesForVSEmbeddings.length > 0) { + for (const knowledgeBase of knowledgeBasesForVSEmbeddings) { + const nodeInstanceFilePath = options.componentNodes['retrieverTool'].filePath as string + const nodeModule = await import(nodeInstanceFilePath) + const newRetrieverToolNodeInstance = new nodeModule.nodeClass() + + const selectedEmbeddingModel = knowledgeBase.embeddingModel + const selectedEmbeddingModelConfig = knowledgeBase.embeddingModelConfig + const embeddingInstanceFilePath = options.componentNodes[selectedEmbeddingModel].filePath as string + const embeddingModule = await import(embeddingInstanceFilePath) + const newEmbeddingInstance = new embeddingModule.nodeClass() + const newEmbeddingNodeData = { + ...nodeData, + credential: selectedEmbeddingModelConfig['FLOWISE_CREDENTIAL_ID'], + inputs: { + ...nodeData.inputs, + ...selectedEmbeddingModelConfig + } + } + const embeddingInstance = await newEmbeddingInstance.init(newEmbeddingNodeData, '', options) + + const selectedVectorStore = knowledgeBase.vectorStore + const selectedVectorStoreConfig = knowledgeBase.vectorStoreConfig + const vectorStoreInstanceFilePath = options.componentNodes[selectedVectorStore].filePath as string + const vectorStoreModule = await import(vectorStoreInstanceFilePath) + const newVectorStoreInstance = new vectorStoreModule.nodeClass() + const newVSNodeData = { + ...nodeData, + credential: selectedVectorStoreConfig['FLOWISE_CREDENTIAL_ID'], + inputs: { + ...nodeData.inputs, + ...selectedVectorStoreConfig, + embeddings: embeddingInstance + }, + outputs: { + output: 'retriever' + } + } + const vectorStoreInstance = await newVectorStoreInstance.init(newVSNodeData, '', options) + + const knowledgeName = knowledgeBase.knowledgeName || '' + + const newRetrieverToolNodeData = { + ...nodeData, + inputs: { + ...nodeData.inputs, + name: sanitizeToolName(knowledgeName), + description: knowledgeBase.knowledgeDescription, + retriever: vectorStoreInstance, + returnSourceDocuments: knowledgeBase.returnSourceDocuments + } + } + const retrieverToolInstance = await newRetrieverToolNodeInstance.init(newRetrieverToolNodeData, '', options) + + toolsInstance.push(retrieverToolInstance as Tool) + + const jsonSchema = zodToJsonSchema(retrieverToolInstance.schema) + if (jsonSchema.$schema) { + delete jsonSchema.$schema + } + const componentNode = options.componentNodes['retrieverTool'] + + availableTools.push({ + name: sanitizeToolName(knowledgeName), + description: knowledgeBase.knowledgeDescription, + schema: jsonSchema, + toolNode: { + label: componentNode?.label || retrieverToolInstance.name, + name: componentNode?.name || retrieverToolInstance.name + } + }) + } + } + + // Extract memory and configuration options + const enableMemory = nodeData.inputs?.agentEnableMemory as boolean + const memoryType = nodeData.inputs?.agentMemoryType as string + const userMessage = nodeData.inputs?.agentUserMessage as string + const _agentUpdateState = nodeData.inputs?.agentUpdateState + const _agentStructuredOutput = nodeData.inputs?.agentStructuredOutput + const agentMessages = (nodeData.inputs?.agentMessages as unknown as ILLMMessage[]) ?? [] + + // Extract runtime state and history + const state = options.agentflowRuntime?.state as ICommonObject + const pastChatHistory = (options.pastChatHistory as BaseMessageLike[]) ?? [] + const runtimeChatHistory = (options.agentflowRuntime?.chatHistory as BaseMessageLike[]) ?? [] + const prependedChatHistory = options.prependedChatHistory as IMessage[] + const chatId = options.chatId as string + + // Initialize the LLM model instance + const nodeInstanceFilePath = options.componentNodes[model].filePath as string + const nodeModule = await import(nodeInstanceFilePath) + const newLLMNodeInstance = new nodeModule.nodeClass() + const newNodeData = { + ...nodeData, + credential: modelConfig['FLOWISE_CREDENTIAL_ID'], + inputs: { + ...nodeData.inputs, + ...modelConfig + } + } + + const llmWithoutToolsBind = (await newLLMNodeInstance.init(newNodeData, '', options)) as BaseChatModel + let llmNodeInstance = llmWithoutToolsBind // save the original LLM instance for later use in withStructuredOutput, getNumTokens + + const isStructuredOutput = _agentStructuredOutput && Array.isArray(_agentStructuredOutput) && _agentStructuredOutput.length > 0 + + const agentToolsBuiltInOpenAI = convertMultiOptionsToStringArray(nodeData.inputs?.agentToolsBuiltInOpenAI) + if (agentToolsBuiltInOpenAI && agentToolsBuiltInOpenAI.length > 0) { + for (const tool of agentToolsBuiltInOpenAI) { + const builtInTool: ICommonObject = { + type: tool + } + if (tool === 'code_interpreter') { + builtInTool.container = { type: 'auto' } + } + ;(toolsInstance as any).push(builtInTool) + ;(availableTools as any).push({ + name: tool, + toolNode: { + label: tool, + name: tool + } + }) + } + } + + const agentToolsBuiltInGemini = convertMultiOptionsToStringArray(nodeData.inputs?.agentToolsBuiltInGemini) + if (agentToolsBuiltInGemini && agentToolsBuiltInGemini.length > 0) { + for (const tool of agentToolsBuiltInGemini) { + const builtInTool: ICommonObject = { + [tool]: {} + } + ;(toolsInstance as any).push(builtInTool) + ;(availableTools as any).push({ + name: tool, + toolNode: { + label: tool, + name: tool + } + }) + } + } + + const agentToolsBuiltInAnthropic = convertMultiOptionsToStringArray(nodeData.inputs?.agentToolsBuiltInAnthropic) + if (agentToolsBuiltInAnthropic && agentToolsBuiltInAnthropic.length > 0) { + for (const tool of agentToolsBuiltInAnthropic) { + // split _ to get the tool name by removing the last part (date) + const toolName = tool.split('_').slice(0, -1).join('_') + + if (tool === 'code_execution_20250825') { + ;(llmNodeInstance as any).clientOptions = { + defaultHeaders: { + 'anthropic-beta': ['code-execution-2025-08-25', 'files-api-2025-04-14'] + } + } + } + + if (tool === 'web_fetch_20250910') { + ;(llmNodeInstance as any).clientOptions = { + defaultHeaders: { + 'anthropic-beta': ['web-fetch-2025-09-10'] + } + } + } + + const builtInTool: ICommonObject = { + type: tool, + name: toolName + } + ;(toolsInstance as any).push(builtInTool) + ;(availableTools as any).push({ + name: tool, + toolNode: { + label: tool, + name: tool + } + }) + } + } + + // Create PlanningTool (write_todos) + const planner = new PlanningTool({ + onUpdate: (todos) => { + const streamer = options.sseStreamer as IServerSideEventStreamer | undefined + // TODO: update UI to consume this event + streamer?.streamCustomEvent(chatId, 'builtin_todos', todos) + } + }) + + const writeTodosTool = new DynamicStructuredTool({ + name: 'write_todos', + description: planner.toolDefinition.description, + schema: z.object({ + todos: z + .array( + z.object({ + content: z.string().describe('Content of the todo item'), + status: z.enum(['pending', 'in_progress', 'completed']).describe('Status of the todo') + }) + ) + .describe('List of todo items to update') + }), + func: async () => '' // Never called — intercepted in handleToolCalls + }) + + toolsInstance.push(writeTodosTool as unknown as Tool) + availableTools.push({ + name: 'write_todos', + description: planner.toolDefinition.description, + schema: planner.toolDefinition.parameters, + toolNode: { label: 'Planning', name: 'write_todos' } + }) + + if (llmNodeInstance && toolsInstance.length > 0) { + if (llmNodeInstance.bindTools === undefined) { + throw new Error(`Agent needs to have a function calling capable models.`) + } + + // @ts-ignore + llmNodeInstance = llmNodeInstance.bindTools(toolsInstance) + } + + // Prepare messages array + const messages: BaseMessageLike[] = [] + + // Prepend history ONLY if it is the first node + if (prependedChatHistory.length > 0 && !runtimeChatHistory.length) { + for (const msg of prependedChatHistory) { + const role: string = msg.role === 'apiMessage' ? 'assistant' : 'user' + const content: string = msg.content ?? '' + messages.push({ + role, + content + }) + } + } + + const userSystemParts: string[] = [] + for (const msg of agentMessages) { + const role = msg.role + const content = msg.content + if (role && content) { + if (role === 'system') { + userSystemParts.push(content) + } else { + messages.push({ role, content }) + } + } + } + + // Build unified system prompt in fixed assembly order + const systemPrompt = buildSystemPrompt({ + todoListPrompt: planner.getSystemPrompt(), + skillsEnabled: false, // TODO: wire to node input + filesystemEnabled: false, // TODO: wire to node input + subagentEnabled: false, // TODO: wire to node input + asyncSubagentEnabled: false, // TODO: wire to node input + userSystemPrompt: userSystemParts.join('\n\n') || undefined + }) + + messages.unshift({ role: 'system', content: systemPrompt }) + + // Handle memory management if enabled + if (enableMemory) { + await this.handleMemory({ + messages, + memoryType, + pastChatHistory, + runtimeChatHistory, + llmWithoutToolsBind, + nodeData, + userMessage, + input, + abortController, + options, + modelConfig + }) + } else if (!runtimeChatHistory.length) { + /* + * If this is the first node: + * - Add images to messages if exist + * - Add user message if it does not exist in the agentMessages array + */ + if (options.uploads) { + const imageContents = await getUniqueImageMessages(options, messages, modelConfig) + if (imageContents) { + messages.push(imageContents.imageMessageWithBase64) + } + } + + if (input && typeof input === 'string' && !agentMessages.some((msg) => msg.role === 'user')) { + messages.push({ + role: 'user', + content: input + }) + } + } + delete nodeData.inputs?.agentMessages + + // Initialize response and determine if streaming is possible + let response: AIMessageChunk = new AIMessageChunk('') + const isLastNode = options.isLastNode as boolean + const streamingConfig = modelConfig?.streaming + const useDefault = streamingConfig == null || streamingConfig === '' + const effectiveStreaming = useDefault + ? newLLMNodeInstance.inputs?.find((i: INodeParams) => i.name === 'streaming')?.default ?? true + : streamingConfig + const isStreamable = isLastNode && options.sseStreamer !== undefined && effectiveStreaming !== false && !isStructuredOutput + + // Start analytics + if (analyticHandlers && options.parentTraceIds) { + const llmLabel = options?.componentNodes?.[model]?.label || model + llmIds = await analyticHandlers.onLLMStart(llmLabel, messages, options.parentTraceIds) + } + + // Handle tool calls with support for recursion + let usedTools: IUsedTool[] = [] + let sourceDocuments: Array = [] + let artifacts: any[] = [] + let fileAnnotations: any[] = [] + let additionalTokens = 0 + let isWaitingForHumanInput = false + let reasonContent = '' + let thinkingDuration: number | undefined + + // Store the current messages length to track which messages are added during tool calls + const messagesBeforeToolCalls = [...messages] + let _toolCallMessages: BaseMessageLike[] = [] + + /** + * Add image artifacts from previous assistant responses as user messages. + * Only the inserted temporary messages contain base64 — other messages are untouched. + */ + await addImageArtifactsToMessages(messages, options) + + // Check if this is hummanInput for tool calls + const _humanInput = nodeData.inputs?.humanInput + const humanInput: IHumanInput = typeof _humanInput === 'string' ? JSON.parse(_humanInput) : _humanInput + const humanInputAction = options.humanInputAction + const iterationContext = options.iterationContext + + // Track execution time + const startTime = Date.now() + + // Get initial response from LLM + const sseStreamer: IServerSideEventStreamer | undefined = options.sseStreamer + + if (humanInput) { + if (humanInput.type !== 'proceed' && humanInput.type !== 'reject') { + throw new Error(`Invalid human input type. Expected 'proceed' or 'reject', but got '${humanInput.type}'`) + } + const result = await this.handleResumedToolCalls({ + humanInput, + humanInputAction, + messages, + toolsInstance, + sseStreamer, + chatId, + input, + options, + abortController, + llmWithoutToolsBind, + isStreamable, + isLastNode, + iterationContext, + isStructuredOutput, + planningTool: planner + }) + + response = result.response + usedTools = result.usedTools + sourceDocuments = result.sourceDocuments + artifacts = result.artifacts + additionalTokens = result.totalTokens + isWaitingForHumanInput = result.isWaitingForHumanInput || false + if (result.accumulatedReasonContent !== undefined) { + reasonContent = result.accumulatedReasonContent + } + if (result.accumulatedReasoningDuration !== undefined) { + thinkingDuration = result.accumulatedReasoningDuration + } + + // Calculate which messages were added during tool calls + _toolCallMessages = messages.slice(messagesBeforeToolCalls.length) + + // Stream additional data if this is the last node + if (isLastNode && sseStreamer) { + if (usedTools.length > 0) { + sseStreamer.streamUsedToolsEvent(chatId, flatten(usedTools)) + } + + if (sourceDocuments.length > 0) { + sseStreamer.streamSourceDocumentsEvent(chatId, flatten(sourceDocuments)) + } + + if (artifacts.length > 0) { + sseStreamer.streamArtifactsEvent(chatId, flatten(artifacts)) + } + } + } else { + if (isStreamable) { + response = await this.handleStreamingResponse( + sseStreamer, + llmNodeInstance, + messages, + chatId, + abortController, + isStructuredOutput, + isLastNode + ) + } else { + response = await llmNodeInstance.invoke(messages, { signal: abortController?.signal }) + } + } + + // Capture reasoning and duration from first LLM response so they can be accumulated across tool-call turns + if (response.additional_kwargs?.reasoning_content) { + reasonContent = (response.additional_kwargs.reasoning_content as string) || '' + } + if (typeof response.additional_kwargs?.reasoning_duration === 'number') { + thinkingDuration = response.additional_kwargs.reasoning_duration + } + + // Address built in tools (after artifacts are processed) + const builtInUsedTools: IUsedTool[] = await this.extractBuiltInUsedTools(response, []) + + if (!humanInput && response.tool_calls && response.tool_calls.length > 0) { + const result = await this.handleToolCalls({ + response, + messages, + toolsInstance, + sseStreamer, + chatId, + input, + options, + abortController, + llmNodeInstance, + isStreamable, + isLastNode, + iterationContext, + isStructuredOutput, + accumulatedReasonContent: reasonContent, + accumulatedReasoningDuration: thinkingDuration, + planningTool: planner + }) + + response = result.response + usedTools = result.usedTools + sourceDocuments = result.sourceDocuments + artifacts = result.artifacts + additionalTokens = result.totalTokens + isWaitingForHumanInput = result.isWaitingForHumanInput || false + if (result.accumulatedReasonContent !== undefined) { + reasonContent = result.accumulatedReasonContent + } + if (result.accumulatedReasoningDuration !== undefined) { + thinkingDuration = result.accumulatedReasoningDuration + } + + // Calculate which messages were added during tool calls + _toolCallMessages = messages.slice(messagesBeforeToolCalls.length) + + // Stream additional data if this is the last node + if (isLastNode && sseStreamer) { + if (usedTools.length > 0) { + sseStreamer.streamUsedToolsEvent(chatId, flatten(usedTools)) + } + + if (sourceDocuments.length > 0) { + sseStreamer.streamSourceDocumentsEvent(chatId, flatten(sourceDocuments)) + } + + if (artifacts.length > 0) { + sseStreamer.streamArtifactsEvent(chatId, flatten(artifacts)) + } + } + } else if (!humanInput && !isStreamable && isLastNode && sseStreamer && !isStructuredOutput) { + // Stream whole response back to UI if not streaming and no tool calls + // Skip this if structured output is enabled - it will be streamed after conversion + + // Stream thinking content if available + if (response.contentBlocks?.length) { + for (const block of response.contentBlocks) { + if (block.type === 'reasoning' && (block as { reasoning?: string }).reasoning) { + reasonContent += (block as { reasoning: string }).reasoning + } + if ((block as any).type === 'thinking' && block.thinking) { + reasonContent += block.thinking + } + } + + sseStreamer.streamThinkingEvent(chatId, reasonContent) + // Send end of thinking event with duration from token details if available + const reasoningTokens = response.usage_metadata?.output_token_details?.reasoning || 0 + // Estimate duration based on reasoning tokens (rough estimate: ~50 tokens/sec) + thinkingDuration = reasoningTokens > 0 ? Math.round(reasoningTokens / 50) : 2 + sseStreamer.streamThinkingEvent(chatId, '', thinkingDuration) + } + + sseStreamer.streamTokenEvent(chatId, extractResponseContent(response)) + } + + // Calculate execution time + const endTime = Date.now() + const timeDelta = endTime - startTime + + // Update flow state if needed + let newState = { ...state } + if (_agentUpdateState && Array.isArray(_agentUpdateState) && _agentUpdateState.length > 0) { + newState = updateFlowState(state, _agentUpdateState) + } + + // Clean up empty inputs + for (const key in nodeData.inputs) { + if (nodeData.inputs[key] === '') { + delete nodeData.inputs[key] + } + } + + // Prepare final response and output object + let finalResponse = '' + if (response.content && Array.isArray(response.content)) { + // Process items and concatenate consecutive text items + const processedParts: string[] = [] + let currentTextBuffer = '' + + for (const item of response.content) { + const itemAny = item as any + const isTextItem = (itemAny.text && !itemAny.type) || (itemAny.type === 'text' && itemAny.text) + + if (isTextItem) { + // Accumulate consecutive text items + currentTextBuffer += itemAny.text + } else { + // Flush accumulated text before processing other types + if (currentTextBuffer) { + processedParts.push(currentTextBuffer) + currentTextBuffer = '' + } + + // Process non-text items + if (itemAny.type === 'executableCode' && itemAny.executableCode) { + // Format executable code as a code block + const language = itemAny.executableCode.language?.toLowerCase() || 'python' + processedParts.push(`\n\`\`\`${language}\n${itemAny.executableCode.code}\n\`\`\`\n`) + } else if (itemAny.type === 'codeExecutionResult' && itemAny.codeExecutionResult) { + // Format code execution result + const outcome = itemAny.codeExecutionResult.outcome || 'OUTCOME_OK' + const output = itemAny.codeExecutionResult.output || '' + if (outcome === 'OUTCOME_OK' && output) { + processedParts.push(`**Code Output:**\n\`\`\`\n${output}\n\`\`\`\n`) + } else if (outcome !== 'OUTCOME_OK') { + processedParts.push(`**Code Execution Error:**\n\`\`\`\n${output}\n\`\`\`\n`) + } + } + } + } + + // Flush any remaining text + if (currentTextBuffer) { + processedParts.push(currentTextBuffer) + } + + finalResponse = processedParts.filter((text) => text).join('\n') + } else if (response.content && typeof response.content === 'string') { + finalResponse = response.content + } else if (response.content === '') { + // Empty response content, this could happen when there is only image data + finalResponse = '' + } else { + finalResponse = JSON.stringify(response, null, 2) + } + + // Address built in tools + const additionalBuiltInUsedTools: IUsedTool[] = await this.extractBuiltInUsedTools(response, builtInUsedTools) + if (additionalBuiltInUsedTools.length > 0) { + usedTools = [...new Set([...usedTools, ...additionalBuiltInUsedTools])] + + // Stream used tools if this is the last node + if (isLastNode && sseStreamer) { + sseStreamer.streamUsedToolsEvent(chatId, flatten(usedTools)) + } + } + + // Extract artifacts from annotations in response metadata and replace inline data + if (response.response_metadata) { + const { + artifacts: extractedArtifacts, + fileAnnotations: extractedFileAnnotations, + savedInlineImages + } = await extractArtifactsFromResponse(response.response_metadata as IResponseMetadata, newNodeData, options) + if (extractedArtifacts.length > 0) { + artifacts = [...artifacts, ...extractedArtifacts] + + // Stream artifacts if this is the last node + if (isLastNode && sseStreamer) { + sseStreamer.streamArtifactsEvent(chatId, extractedArtifacts) + } + } + + if (extractedFileAnnotations.length > 0) { + fileAnnotations = [...fileAnnotations, ...extractedFileAnnotations] + + // Stream file annotations if this is the last node + if (isLastNode && sseStreamer) { + sseStreamer.streamFileAnnotationsEvent(chatId, fileAnnotations) + } + } + + // Replace inlineData base64 with file references in the response + if (savedInlineImages && savedInlineImages.length > 0) { + replaceInlineDataWithFileReferences(response, savedInlineImages) + } + } + + // Replace sandbox links with proper download URLs. Example: [Download the script](sandbox:/mnt/data/dummy_bar_graph.py) + if (finalResponse.includes('sandbox:/')) { + finalResponse = await this.processSandboxLinks(finalResponse, options.baseURL, options.chatflowid, chatId) + } + + // If is structured output, then invoke LLM again with structured output at the very end after all tool calls + if (isStructuredOutput) { + const structuredllmNodeInstance = configureStructuredOutput(llmWithoutToolsBind, _agentStructuredOutput) + const prompt = 'Convert the following response to the structured output format: ' + finalResponse + response = await structuredllmNodeInstance.invoke(prompt, { signal: abortController?.signal }) + + // Prefix the response with ```json and suffix with ``` to render as a code block + if (typeof response === 'object') { + finalResponse = '```json\n' + JSON.stringify(response, null, 2) + '\n```' + } else { + finalResponse = response + } + + if (isLastNode && sseStreamer) { + sseStreamer.streamTokenEvent(chatId, finalResponse) + } + } + + // Add reasoning content + if (!reasonContent && response.additional_kwargs?.reasoning_content) { + reasonContent = response.additional_kwargs.reasoning_content as string + } + if (reasonContent && response.additional_kwargs?.reasoning_duration != null) { + thinkingDuration = response.additional_kwargs.reasoning_duration as number + } + const reasonContentObj = + reasonContent !== undefined && reasonContent !== '' ? { thinking: reasonContent, thinkingDuration } : undefined + + const costMetadata = await this.calculateUsageCost( + model, + modelConfig?.modelName as string | undefined, + response.usage_metadata, + additionalTokens + ) + + const output = this.prepareOutputObject( + response, + availableTools, + finalResponse, + startTime, + endTime, + timeDelta, + usedTools, + sourceDocuments, + artifacts, + additionalTokens, + isWaitingForHumanInput, + fileAnnotations, + isStructuredOutput, + reasonContentObj, + costMetadata + ) + + // End analytics tracking + if (analyticHandlers && llmIds) { + await analyticHandlers.onLLMEnd(llmIds, output, { model: modelName, provider: model }) + } + + // Send additional streaming events if needed + if (isStreamable) { + this.sendStreamingEvents(options, chatId, response) + } + + // Stream file annotations if any were extracted + if (fileAnnotations.length > 0 && isLastNode && sseStreamer) { + sseStreamer.streamFileAnnotationsEvent(chatId, fileAnnotations) + } + + // Process template variables in state + const outputForStateProcessing = + isStructuredOutput && typeof response === 'object' ? JSON.stringify(response, null, 2) : finalResponse + newState = processTemplateVariables(newState, outputForStateProcessing) + + /** + * Remove temporary artifact image messages (they were only needed for the model invoke). + * Then revert all remaining tagged base64 image_url items back to stored-file format. + * This is to avoid storing the actual base64 data into database + */ + const messagesToStore = messages.filter((msg: any) => !msg._isTemporaryImageMessage) + const normalizedMessagesToStore = normalizeMessagesForStorage(messagesToStore) + const messagesWithFileReferences = revertBase64ImagesToFileRefs(normalizedMessagesToStore) + + // Only add to runtime chat history if this is the first node + const inputMessages = [] + if (!runtimeChatHistory.length) { + // Include any image file reference messages from uploads in the chat history + const imageInputMessages = messagesWithFileReferences.filter( + (msg: any) => + msg.role === 'user' && + Array.isArray(msg.content) && + msg.content.some((item: any) => item.type === 'stored-file' && item.mime?.startsWith('image/')) + ) + if (imageInputMessages.length) { + inputMessages.push(...imageInputMessages) + } + if (input && typeof input === 'string') { + if (!enableMemory) { + if (!agentMessages.some((msg) => msg.role === 'user')) { + inputMessages.push({ role: 'user', content: input }) + } else { + agentMessages.map((msg) => { + if (msg.role === 'user') { + inputMessages.push({ role: 'user', content: msg.content }) + } + }) + } + } else { + inputMessages.push({ role: 'user', content: input }) + } + } + } + + const returnResponseAs = nodeData.inputs?.agentReturnResponseAs as string + let returnRole = 'user' + if (returnResponseAs === 'assistantMessage') { + returnRole = 'assistant' + } + + // Prepare and return the final output + return { + id: nodeData.id, + name: this.name, + input: { + messages: messagesWithFileReferences, + ...nodeData.inputs + }, + output, + state: newState, + chatHistory: [ + ...inputMessages, + + // Add the messages that were specifically added during tool calls, this enable other nodes to see the full tool call history, temporaraily disabled + // ...toolCallMessages, + + // End with the final assistant response + { + role: returnRole, + content: finalResponse, + name: nodeData?.label ? nodeData?.label.toLowerCase().replace(/\s/g, '_').trim() : nodeData?.id, + ...(((artifacts && artifacts.length > 0) || + (fileAnnotations && fileAnnotations.length > 0) || + (usedTools && usedTools.length > 0)) && { + additional_kwargs: { + ...(artifacts && artifacts.length > 0 && { artifacts }), + ...(fileAnnotations && fileAnnotations.length > 0 && { fileAnnotations }), + ...(usedTools && usedTools.length > 0 && { usedTools }) + } + }) + } + ] + } + } catch (error) { + if (options.analyticHandlers && llmIds) { + await options.analyticHandlers.onLLMError(llmIds, error instanceof Error ? error.message : String(error)) + } + + if (error instanceof Error && error.message === 'Aborted') { + throw error + } + throw new Error(`Error in Agent node: ${error instanceof Error ? error.message : String(error)}`) + } + } + + /** + * Extracts built-in used tools from response metadata and processes image generation results + */ + private async extractBuiltInUsedTools(response: AIMessageChunk, builtInUsedTools: IUsedTool[] = []): Promise { + if (!response.response_metadata) { + return builtInUsedTools + } + + const { output, tools, groundingMetadata, urlContextMetadata } = response.response_metadata as { + output?: any[] + tools?: any[] + groundingMetadata?: { webSearchQueries?: string[] } + urlContextMetadata?: { urlMetadata?: any[] } + } + + // Handle OpenAI built-in tools + if (output && Array.isArray(output) && output.length > 0 && tools && Array.isArray(tools) && tools.length > 0) { + for (const outputItem of output) { + if (outputItem.type && outputItem.type.endsWith('_call')) { + let toolInput = outputItem.action ?? outputItem.code + let toolOutput = outputItem.status === 'completed' ? 'Success' : outputItem.status + + // Handle image generation calls specially + if (outputItem.type === 'image_generation_call') { + // Create input summary for image generation + toolInput = { + prompt: outputItem.revised_prompt || 'Image generation request', + size: outputItem.size || '1024x1024', + quality: outputItem.quality || 'standard', + output_format: outputItem.output_format || 'png' + } + + // Check if image has been processed (base64 replaced with file path) + if (outputItem.result && !outputItem.result.startsWith('data:') && !outputItem.result.includes('base64')) { + toolOutput = `Image generated and saved` + } else { + toolOutput = `Image generated (base64)` + } + } + + // Remove "_call" suffix to get the base tool name + const baseToolName = outputItem.type.replace('_call', '') + + // Find matching tool that includes the base name in its type + const matchingTool = tools.find((tool) => tool.type && tool.type.includes(baseToolName)) + + if (matchingTool) { + // Check for duplicates + if (builtInUsedTools.find((tool) => tool.tool === matchingTool.type)) { + continue + } + + builtInUsedTools.push({ + tool: matchingTool.type, + toolInput, + toolOutput + }) + } + } + } + } + + // Handle Gemini googleSearch tool + if (groundingMetadata && groundingMetadata.webSearchQueries && Array.isArray(groundingMetadata.webSearchQueries)) { + // Check for duplicates + const isDuplicate = builtInUsedTools.find( + (tool) => + tool.tool === 'googleSearch' && + JSON.stringify((tool.toolInput as any)?.queries) === JSON.stringify(groundingMetadata.webSearchQueries) + ) + if (!isDuplicate) { + builtInUsedTools.push({ + tool: 'googleSearch', + toolInput: { + queries: groundingMetadata.webSearchQueries + }, + toolOutput: `Searched for: ${groundingMetadata.webSearchQueries.join(', ')}` + }) + } + } + + // Handle Gemini urlContext tool + if (urlContextMetadata && urlContextMetadata.urlMetadata && Array.isArray(urlContextMetadata.urlMetadata)) { + // Check for duplicates + const isDuplicate = builtInUsedTools.find( + (tool) => + tool.tool === 'urlContext' && + JSON.stringify((tool.toolInput as any)?.urlMetadata) === JSON.stringify(urlContextMetadata.urlMetadata) + ) + if (!isDuplicate) { + builtInUsedTools.push({ + tool: 'urlContext', + toolInput: { + urlMetadata: urlContextMetadata.urlMetadata + }, + toolOutput: `Processed ${urlContextMetadata.urlMetadata.length} URL(s)` + }) + } + } + + // Handle Gemini codeExecution tool + if (response.content && Array.isArray(response.content)) { + for (let i = 0; i < response.content.length; i++) { + const item = response.content[i] + + if (item.type === 'executableCode' && item.executableCode) { + const executableCode = item.executableCode as { language?: string; code?: string } + const language = executableCode.language || 'PYTHON' + const code = executableCode.code || '' + let toolOutput = '' + + // Check for duplicates + const isDuplicate = builtInUsedTools.find( + (tool) => + tool.tool === 'codeExecution' && + (tool.toolInput as any)?.language === language && + (tool.toolInput as any)?.code === code + ) + if (isDuplicate) { + continue + } + + // Check the next item for the output + const nextItem = i + 1 < response.content.length ? response.content[i + 1] : null + + if (nextItem) { + if (nextItem.type === 'codeExecutionResult' && nextItem.codeExecutionResult) { + const codeExecutionResult = nextItem.codeExecutionResult as { outcome?: string; output?: string } + const outcome = codeExecutionResult.outcome + const output = codeExecutionResult.output || '' + toolOutput = outcome === 'OUTCOME_OK' ? output : `Error: ${output}` + } else if (nextItem.type === 'inlineData') { + toolOutput = 'Generated image data' + } + } + + builtInUsedTools.push({ + tool: 'codeExecution', + toolInput: { + language, + code + }, + toolOutput + }) + } + } + } + + return builtInUsedTools + } + + /** + * Handles memory management based on the specified memory type + */ + private async handleMemory({ + messages, + memoryType, + pastChatHistory, + runtimeChatHistory, + llmWithoutToolsBind, + nodeData, + userMessage, + input, + abortController, + options, + modelConfig + }: { + messages: BaseMessageLike[] + memoryType: string + pastChatHistory: BaseMessageLike[] + runtimeChatHistory: BaseMessageLike[] + llmWithoutToolsBind: BaseChatModel + nodeData: INodeData + userMessage: string + input: string | Record + abortController: AbortController + options: ICommonObject + modelConfig: ICommonObject + }): Promise { + const { updatedPastMessages } = await getPastChatHistoryImageMessages(pastChatHistory, options) + pastChatHistory = updatedPastMessages + + let pastMessages = [...pastChatHistory, ...runtimeChatHistory] + if (!runtimeChatHistory.length && input && typeof input === 'string') { + /* + * If this is the first node: + * - Add images to messages if exist + * - Add user message + */ + if (options.uploads) { + const imageContents = await getUniqueImageMessages(options, messages, modelConfig) + if (imageContents) { + pastMessages.push(imageContents.imageMessageWithBase64) + } + } + pastMessages.push({ + role: 'user', + content: input + }) + } + const { updatedMessages } = await processMessagesWithImages(pastMessages, options) + pastMessages = updatedMessages + + if (pastMessages.length > 0) { + if (memoryType === 'windowSize') { + // Window memory: Keep the last N messages + const windowSize = nodeData.inputs?.agentMemoryWindowSize as number + const windowedMessages = pastMessages.slice(-windowSize * 2) + messages.push(...windowedMessages) + } else if (memoryType === 'conversationSummary') { + // Summary memory: Summarize all past messages + const summary = await llmWithoutToolsBind.invoke( + [ + { + role: 'user', + content: DEFAULT_SUMMARIZER_TEMPLATE.replace( + '{conversation}', + pastMessages.map((msg: any) => `${msg.role}: ${msg.content}`).join('\n') + ) + } + ], + { signal: abortController?.signal } + ) + messages.push({ role: 'assistant', content: extractResponseContent(summary) }) + if (!userMessage && input && typeof input === 'string') { + messages.push({ + role: 'user', + content: input + }) + } + } else if (memoryType === 'conversationSummaryBuffer') { + // Summary buffer: Summarize messages that exceed token limit + await this.handleSummaryBuffer(messages, pastMessages, llmWithoutToolsBind, nodeData, abortController) + } else { + // Default: Use all messages + messages.push(...pastMessages) + } + } + + // Add user message + if (userMessage) { + messages.push({ + role: 'user', + content: userMessage + }) + } + } + + /** + * Handles conversation summary buffer memory type + */ + private async handleSummaryBuffer( + messages: BaseMessageLike[], + pastMessages: BaseMessageLike[], + llmWithoutToolsBind: BaseChatModel, + nodeData: INodeData, + abortController: AbortController + ): Promise { + const maxTokenLimit = (nodeData.inputs?.agentMemoryMaxTokenLimit as number) || 2000 + + // Convert past messages to a format suitable for token counting + const messagesString = pastMessages.map((msg: any) => `${msg.role}: ${msg.content}`).join('\n') + const tokenCount = await llmWithoutToolsBind.getNumTokens(messagesString) + + if (tokenCount > maxTokenLimit) { + // Calculate how many messages to summarize (messages that exceed the token limit) + let currBufferLength = tokenCount + const messagesToSummarize = [] + const remainingMessages = [...pastMessages] + + // Remove messages from the beginning until we're under the token limit + while (currBufferLength > maxTokenLimit && remainingMessages.length > 0) { + const poppedMessage = remainingMessages.shift() + if (poppedMessage) { + messagesToSummarize.push(poppedMessage) + // Recalculate token count for remaining messages + const remainingMessagesString = remainingMessages.map((msg: any) => `${msg.role}: ${msg.content}`).join('\n') + currBufferLength = await llmWithoutToolsBind.getNumTokens(remainingMessagesString) + } + } + + // Summarize the messages that were removed + const messagesToSummarizeString = messagesToSummarize.map((msg: any) => `${msg.role}: ${msg.content}`).join('\n') + + const summary = await llmWithoutToolsBind.invoke( + [ + { + role: 'user', + content: DEFAULT_SUMMARIZER_TEMPLATE.replace('{conversation}', messagesToSummarizeString) + } + ], + { signal: abortController?.signal } + ) + + // Add summary as a system message at the beginning, then add remaining messages + let summaryRole = 'system' + if (messages.some((msg) => typeof msg === 'object' && !Array.isArray(msg) && 'role' in msg && msg.role === 'system')) { + summaryRole = 'user' // some model doesn't allow multiple system messages + } + messages.push({ role: summaryRole, content: `Previous conversation summary: ${extractResponseContent(summary)}` }) + messages.push(...remainingMessages) + } else { + // If under token limit, use all messages + messages.push(...pastMessages) + } + } + + /** + * Handles streaming response from the LLM + */ + private async handleStreamingResponse( + sseStreamer: IServerSideEventStreamer | undefined, + llmNodeInstance: BaseChatModel, + messages: BaseMessageLike[], + chatId: string, + abortController: AbortController, + isStructuredOutput: boolean = false, + isLastNode: boolean = false + ): Promise { + let response = new AIMessageChunk('') + let reasonContent = '' + let thinkingDuration: number | undefined + let thinkingStartTime: number | null = null + let wasThinking = false + let sentLastThinkingEvent = false + + try { + for await (const chunk of await llmNodeInstance.stream(messages, { signal: abortController?.signal })) { + if (sseStreamer && !isStructuredOutput) { + let content = '' + + if (chunk.contentBlocks?.length) { + for (const block of chunk.contentBlocks) { + if (isLastNode) { + // As soon as we see the first non-reasoning block, send last thinking event with duration + if (block.type !== 'reasoning' && wasThinking && !sentLastThinkingEvent && thinkingStartTime != null) { + thinkingDuration = Math.round((Date.now() - thinkingStartTime) / 1000) + sseStreamer.streamThinkingEvent(chatId, '', thinkingDuration) + sentLastThinkingEvent = true + } + if (block.type === 'reasoning' && (block as { reasoning?: string }).reasoning) { + if (!thinkingStartTime) { + thinkingStartTime = Date.now() + } + wasThinking = true + const reasoningContent = (block as { reasoning: string }).reasoning + sseStreamer.streamThinkingEvent(chatId, reasoningContent) + reasonContent += reasoningContent + } + } + } + } + + if (typeof chunk === 'string') { + content = chunk + } else if (Array.isArray(chunk.content) && chunk.content.length > 0) { + content = chunk.content + .map((item: any) => { + if ((item.text && !item.type) || (item.type === 'text' && item.text)) { + return item.text + } else if (item.type === 'executableCode' && item.executableCode) { + const language = item.executableCode.language?.toLowerCase() || 'python' + return `\n\`\`\`${language}\n${item.executableCode.code}\n\`\`\`\n` + } else if (item.type === 'codeExecutionResult' && item.codeExecutionResult) { + const outcome = item.codeExecutionResult.outcome || 'OUTCOME_OK' + const output = item.codeExecutionResult.output || '' + if (outcome === 'OUTCOME_OK' && output) { + return `**Code Output:**\n\`\`\`\n${output}\n\`\`\`\n` + } else if (outcome !== 'OUTCOME_OK') { + return `**Code Execution Error:**\n\`\`\`\n${output}\n\`\`\`\n` + } + } + return '' + }) + .filter((text: string) => text) + .join('') + } else if (chunk.content) { + content = chunk.content.toString() + } + sseStreamer.streamTokenEvent(chatId, content) + } + + const messageChunk = typeof chunk === 'string' ? new AIMessageChunk(chunk) : chunk + response = response.concat(messageChunk) + } + } catch (error) { + console.error('Error during streaming:', error) + throw error + } + + // Only convert to string if all content items are text (no inlineData or other special types) + if (Array.isArray(response.content) && response.content.length > 0) { + const hasNonTextContent = response.content.some( + (item: any) => item.type === 'inlineData' || item.type === 'executableCode' || item.type === 'codeExecutionResult' + ) + if (!hasNonTextContent) { + const responseContents = response.content as ContentBlock.Text[] + response.content = responseContents.map((item) => item.text).join('') + } + } + + if (reasonContent.length > 0) { + response.additional_kwargs = { + ...response.additional_kwargs, + reasoning_content: reasonContent, + reasoning_duration: thinkingDuration + } + } + + return response + } + + /** + * Calculates input/output and total cost from usage metadata using model pricing from models.json. + * Also returns the model's base (per-token) input and output costs. + */ + private async calculateUsageCost( + provider: string | undefined, + modelName: string | undefined, + usageMetadata: Record | undefined, + additionalTokens: number = 0 + ): Promise< + | { + input_cost: number + output_cost: number + total_cost: number + base_input_cost: number + base_output_cost: number + } + | undefined + > { + if (!provider || !modelName) return undefined + const inputTokens = (usageMetadata?.input_tokens ?? 0) as number + const outputTokens = ((usageMetadata?.output_tokens ?? 0) as number) + additionalTokens + try { + const modelConfig = await getModelConfigByModelName(MODEL_TYPE.CHAT, provider, modelName) + if (!modelConfig) return undefined + const baseInputCost = Number(modelConfig.input_cost) || 0 + const baseOutputCost = Number(modelConfig.output_cost) || 0 + const inputCost = inputTokens * baseInputCost + const outputCost = outputTokens * baseOutputCost + const totalCost = inputCost + outputCost + if (inputCost === 0 && outputCost === 0) return undefined + return { + input_cost: inputCost, + output_cost: outputCost, + total_cost: totalCost, + base_input_cost: baseInputCost, + base_output_cost: baseOutputCost + } + } catch { + return undefined + } + } + + /** + * Prepares the output object with response and metadata + */ + private prepareOutputObject( + response: AIMessageChunk, + availableTools: ISimpliefiedTool[], + finalResponse: string, + startTime: number, + endTime: number, + timeDelta: number, + usedTools: IUsedTool[], + sourceDocuments: Array, + artifacts: any[], + additionalTokens: number = 0, + isWaitingForHumanInput: boolean = false, + fileAnnotations: any[] = [], + isStructuredOutput: boolean = false, + reasonContent?: { thinking: string; thinkingDuration?: number }, + costMetadata?: { + input_cost: number + output_cost: number + total_cost: number + base_input_cost: number + base_output_cost: number + } + ): any { + const output: any = { + content: finalResponse, + timeMetadata: { + start: startTime, + end: endTime, + delta: timeDelta + } + } + + if (response.tool_calls) { + output.calledTools = response.tool_calls + } + + // Include token usage metadata with accumulated tokens from tool calls + if (response.usage_metadata) { + const originalTokens = response.usage_metadata.total_tokens || 0 + output.usageMetadata = { + ...response.usage_metadata, + total_tokens: originalTokens + additionalTokens, + tool_call_tokens: additionalTokens + } + } else if (additionalTokens > 0) { + // If no original usage metadata but we have tool tokens + output.usageMetadata = { + total_tokens: additionalTokens, + tool_call_tokens: additionalTokens + } + } + + if (costMetadata && output.usageMetadata) { + output.usageMetadata.input_cost = costMetadata.input_cost + output.usageMetadata.output_cost = costMetadata.output_cost + output.usageMetadata.total_cost = costMetadata.total_cost + output.usageMetadata.base_input_cost = costMetadata.base_input_cost + output.usageMetadata.base_output_cost = costMetadata.base_output_cost + } + + if (response.response_metadata) { + output.responseMetadata = response.response_metadata + } + + if (isStructuredOutput && typeof response === 'object') { + const structuredOutput = response as Record + for (const key in structuredOutput) { + if (structuredOutput[key] !== undefined && structuredOutput[key] !== null) { + output[key] = structuredOutput[key] + } + } + } + + // Add used tools, source documents and artifacts to output + if (usedTools && usedTools.length > 0) { + output.usedTools = flatten(usedTools) + } + + if (sourceDocuments && sourceDocuments.length > 0) { + output.sourceDocuments = flatten(sourceDocuments) + } + + if (artifacts && artifacts.length > 0) { + output.artifacts = flatten(artifacts) + } + + if (availableTools && availableTools.length > 0) { + output.availableTools = availableTools + } + + if (isWaitingForHumanInput) { + output.isWaitingForHumanInput = isWaitingForHumanInput + } + + if (fileAnnotations && fileAnnotations.length > 0) { + output.fileAnnotations = fileAnnotations + } + + if (reasonContent) { + output.reasonContent = reasonContent + } + + return output + } + + /** + * Sends additional streaming events for tool calls and metadata + */ + private sendStreamingEvents(options: ICommonObject, chatId: string, response: AIMessageChunk): void { + const sseStreamer: IServerSideEventStreamer = options.sseStreamer as IServerSideEventStreamer + + if (response.tool_calls) { + const formattedToolCalls = response.tool_calls.map((toolCall: any) => ({ + tool: toolCall.name || 'tool', + toolInput: toolCall.args, + toolOutput: '' + })) + sseStreamer.streamCalledToolsEvent(chatId, flatten(formattedToolCalls)) + } + + if (response.usage_metadata) { + sseStreamer.streamUsageMetadataEvent(chatId, response.usage_metadata) + } + + sseStreamer.streamEndEvent(chatId) + } + + /** + * Handles tool calls and their responses, with support for recursive tool calling + */ + private async handleToolCalls({ + response, + messages, + toolsInstance, + sseStreamer, + chatId, + input, + options, + abortController, + llmNodeInstance, + isStreamable, + isLastNode, + iterationContext, + isStructuredOutput = false, + accumulatedReasonContent: initialAccumulatedReasonContent, + accumulatedReasoningDuration: initialAccumulatedReasoningDuration, + planningTool + }: { + response: AIMessageChunk + messages: BaseMessageLike[] + toolsInstance: Tool[] + sseStreamer: IServerSideEventStreamer | undefined + chatId: string + input: string | Record + options: ICommonObject + abortController: AbortController + llmNodeInstance: BaseChatModel + isStreamable: boolean + isLastNode: boolean + iterationContext: ICommonObject + isStructuredOutput?: boolean + accumulatedReasonContent?: string + accumulatedReasoningDuration?: number + planningTool?: PlanningTool + }): Promise<{ + response: AIMessageChunk + usedTools: IUsedTool[] + sourceDocuments: Array + artifacts: any[] + totalTokens: number + isWaitingForHumanInput?: boolean + accumulatedReasonContent?: string + accumulatedReasoningDuration?: number + }> { + // Track total tokens used throughout this process + let totalTokens = response.usage_metadata?.total_tokens || 0 + const usedTools: IUsedTool[] = [] + let sourceDocuments: Array = [] + let artifacts: any[] = [] + let isWaitingForHumanInput: boolean | undefined + // Use reasoning from caller (first turn); subsequent turns are added when we get newResponse + let accumulatedReasonContent = initialAccumulatedReasonContent ?? '' + let accumulatedReasoningDuration = initialAccumulatedReasoningDuration ?? 0 + + if (!response.tool_calls || response.tool_calls.length === 0) { + return { + response, + usedTools: [], + sourceDocuments: [], + artifacts: [], + totalTokens, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + + // Stream tool calls if available + if (sseStreamer) { + const formattedToolCalls = response.tool_calls.map((toolCall: any) => ({ + tool: toolCall.name || 'tool', + toolInput: toolCall.args, + toolOutput: '' + })) + sseStreamer.streamCalledToolsEvent(chatId, flatten(formattedToolCalls)) + } + + // Remove tool calls with no id + const toBeRemovedToolCalls = [] + for (let i = 0; i < response.tool_calls.length; i++) { + const toolCall = response.tool_calls[i] + if (!toolCall.id) { + toBeRemovedToolCalls.push(toolCall) + usedTools.push({ + tool: toolCall.name || 'tool', + toolInput: toolCall.args, + toolOutput: response.content + }) + } + } + + for (const toolCall of toBeRemovedToolCalls) { + response.tool_calls.splice(response.tool_calls.indexOf(toolCall), 1) + } + + // Add LLM response with tool calls to messages + messages.push(response) + + // Process each tool call + for (let i = 0; i < response.tool_calls.length; i++) { + const toolCall = response.tool_calls[i] + + // Intercept write_todos — handled by PlanningTool, not the LangChain tool + if (toolCall.name === 'write_todos' && planningTool) { + const toolOutput = planningTool.handleToolCall(toolCall.args as { todos: Todo[] }) + messages.push({ role: 'tool', content: toolOutput, tool_call_id: toolCall.id, name: toolCall.name }) + usedTools.push({ tool: 'write_todos', toolInput: toolCall.args, toolOutput }) + continue + } + + const selectedTool = toolsInstance.find((tool) => tool.name === toolCall.name) + if (selectedTool) { + let parsedDocs + let parsedArtifacts + let isToolRequireHumanInput = + (selectedTool as any).requiresHumanInput && (!iterationContext || Object.keys(iterationContext).length === 0) + + const flowConfig = { + chatflowId: options.chatflowid, + sessionId: options.sessionId, + chatId: options.chatId, + input: input, + state: options.agentflowRuntime?.state + } + + if (isToolRequireHumanInput) { + const toolCallDetails = '```json\n' + JSON.stringify(toolCall, null, 2) + '\n```' + const responseContent = response.content + `\nAttempting to use tool:\n${toolCallDetails}` + response.content = responseContent + if (!isStructuredOutput) { + sseStreamer?.streamTokenEvent(chatId, responseContent) + } + return { + response, + usedTools, + sourceDocuments, + artifacts, + totalTokens, + isWaitingForHumanInput: true, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + + let toolIds: ICommonObject | undefined + if (options.analyticHandlers) { + toolIds = await options.analyticHandlers.onToolStart(toolCall.name, toolCall.args, options.parentTraceIds) + } + + try { + //@ts-ignore + let toolOutput = await selectedTool.call(toolCall.args, { signal: abortController?.signal }, undefined, flowConfig) + + if (options.analyticHandlers && toolIds) { + await options.analyticHandlers.onToolEnd(toolIds, toolOutput) + } + + // Extract source documents if present + if (typeof toolOutput === 'string' && toolOutput.includes(SOURCE_DOCUMENTS_PREFIX)) { + const [output, docs] = toolOutput.split(SOURCE_DOCUMENTS_PREFIX) + toolOutput = output + try { + parsedDocs = JSON.parse(docs) + sourceDocuments.push(parsedDocs) + } catch (e) { + console.error('Error parsing source documents from tool:', e) + } + } + + // Extract artifacts if present + if (typeof toolOutput === 'string' && toolOutput.includes(ARTIFACTS_PREFIX)) { + const [output, artifact] = toolOutput.split(ARTIFACTS_PREFIX) + toolOutput = output + try { + parsedArtifacts = JSON.parse(artifact) + artifacts.push(parsedArtifacts) + } catch (e) { + console.error('Error parsing artifacts from tool:', e) + } + } + + let toolInput + if (typeof toolOutput === 'string' && toolOutput.includes(TOOL_ARGS_PREFIX)) { + const [output, args] = toolOutput.split(TOOL_ARGS_PREFIX) + toolOutput = output + try { + toolInput = JSON.parse(args) + } catch (e) { + console.error('Error parsing tool input from tool:', e) + } + } + + // Add tool message to conversation + messages.push({ + role: 'tool', + content: toolOutput, + tool_call_id: toolCall.id, + name: toolCall.name, + additional_kwargs: { + artifacts: parsedArtifacts, + sourceDocuments: parsedDocs + } + }) + + // Track used tools + usedTools.push({ + tool: toolCall.name, + toolInput: toolInput ?? toolCall.args, + toolOutput + }) + } catch (e) { + if (options.analyticHandlers && toolIds) { + await options.analyticHandlers.onToolEnd(toolIds, e) + } + + console.error('Error invoking tool:', e) + const errMsg = getErrorMessage(e) + let toolInput = toolCall.args + if (typeof errMsg === 'string' && errMsg.includes(TOOL_ARGS_PREFIX)) { + const [_, args] = errMsg.split(TOOL_ARGS_PREFIX) + try { + toolInput = JSON.parse(args) + } catch (e) { + console.error('Error parsing tool input from tool:', e) + } + } + + usedTools.push({ + tool: selectedTool.name, + toolInput, + toolOutput: '', + error: getErrorMessage(e) + }) + sseStreamer?.streamUsedToolsEvent(chatId, flatten(usedTools)) + throw new Error(getErrorMessage(e)) + } + } + } + + // Return direct tool output if there's exactly one tool with returnDirect + if (response.tool_calls.length === 1) { + const selectedTool = toolsInstance.find((tool) => tool.name === response.tool_calls?.[0]?.name) + if (selectedTool && selectedTool.returnDirect) { + const lastToolOutput = usedTools[0]?.toolOutput || '' + const lastToolOutputString = typeof lastToolOutput === 'string' ? lastToolOutput : JSON.stringify(lastToolOutput, null, 2) + + if (sseStreamer && !isStructuredOutput) { + sseStreamer.streamTokenEvent(chatId, lastToolOutputString) + } + + return { + response: new AIMessageChunk(lastToolOutputString), + usedTools, + sourceDocuments, + artifacts, + totalTokens, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + } + + if (response.tool_calls.length === 0) { + const responseContent = extractResponseContent(response) + return { + response: new AIMessageChunk(responseContent), + usedTools, + sourceDocuments, + artifacts, + totalTokens, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + + // Get LLM response after tool calls + let newResponse: AIMessageChunk + + if (isStreamable) { + newResponse = await this.handleStreamingResponse( + sseStreamer, + llmNodeInstance, + messages, + chatId, + abortController, + isStructuredOutput, + isLastNode + ) + } else { + newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal }) + + // Stream non-streaming response if this is the last node + if (isLastNode && sseStreamer && !isStructuredOutput) { + sseStreamer.streamTokenEvent(chatId, extractResponseContent(newResponse)) + } + } + + // Add tokens from this response + if (newResponse.usage_metadata?.total_tokens) { + totalTokens += newResponse.usage_metadata.total_tokens + } + + // Accumulate this turn's reasoning content and duration + if (newResponse.additional_kwargs?.reasoning_content) { + const chunkReason = newResponse.additional_kwargs.reasoning_content as string + accumulatedReasonContent += (accumulatedReasonContent ? '\n\n' : '') + chunkReason + } + if (typeof newResponse.additional_kwargs?.reasoning_duration === 'number') { + accumulatedReasoningDuration += newResponse.additional_kwargs.reasoning_duration + } + + // Check for recursive tool calls and handle them + if (newResponse.tool_calls && newResponse.tool_calls.length > 0) { + const { + response: recursiveResponse, + usedTools: recursiveUsedTools, + sourceDocuments: recursiveSourceDocuments, + artifacts: recursiveArtifacts, + totalTokens: recursiveTokens, + isWaitingForHumanInput: recursiveIsWaitingForHumanInput, + accumulatedReasonContent: recursiveAccumulatedReasonContent, + accumulatedReasoningDuration: recursiveAccumulatedReasoningDuration + } = await this.handleToolCalls({ + response: newResponse, + messages, + toolsInstance, + sseStreamer, + chatId, + input, + options, + abortController, + llmNodeInstance, + isStreamable, + isLastNode, + iterationContext, + isStructuredOutput, + accumulatedReasonContent, + accumulatedReasoningDuration, + planningTool + }) + + // Merge results from recursive tool calls + newResponse = recursiveResponse + usedTools.push(...recursiveUsedTools) + sourceDocuments = [...sourceDocuments, ...recursiveSourceDocuments] + artifacts = [...artifacts, ...recursiveArtifacts] + totalTokens += recursiveTokens + isWaitingForHumanInput = recursiveIsWaitingForHumanInput + if (recursiveAccumulatedReasonContent !== undefined) { + accumulatedReasonContent = recursiveAccumulatedReasonContent + } + if (recursiveAccumulatedReasoningDuration !== undefined) { + accumulatedReasoningDuration = recursiveAccumulatedReasoningDuration + } + } + + return { + response: newResponse, + usedTools, + sourceDocuments, + artifacts, + totalTokens, + isWaitingForHumanInput, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + + /** + * Handles tool calls and their responses, with support for recursive tool calling + */ + private async handleResumedToolCalls({ + humanInput, + humanInputAction, + messages, + toolsInstance, + sseStreamer, + chatId, + input, + options, + abortController, + llmWithoutToolsBind, + isStreamable, + isLastNode, + iterationContext, + isStructuredOutput = false, + planningTool + }: { + humanInput: IHumanInput + humanInputAction: Record | undefined + messages: BaseMessageLike[] + toolsInstance: Tool[] + sseStreamer: IServerSideEventStreamer | undefined + chatId: string + input: string | Record + options: ICommonObject + abortController: AbortController + llmWithoutToolsBind: BaseChatModel + isStreamable: boolean + isLastNode: boolean + iterationContext: ICommonObject + isStructuredOutput?: boolean + planningTool?: PlanningTool + }): Promise<{ + response: AIMessageChunk + usedTools: IUsedTool[] + sourceDocuments: Array + artifacts: any[] + totalTokens: number + isWaitingForHumanInput?: boolean + accumulatedReasonContent?: string + accumulatedReasoningDuration?: number + }> { + let llmNodeInstance = llmWithoutToolsBind + const usedTools: IUsedTool[] = [] + let sourceDocuments: Array = [] + let artifacts: any[] = [] + let isWaitingForHumanInput: boolean | undefined + + const lastCheckpointMessages = humanInputAction?.data?.input?.messages ?? [] + if (!lastCheckpointMessages.length) { + return { + response: new AIMessageChunk(''), + usedTools: [], + sourceDocuments: [], + artifacts: [], + totalTokens: 0, + accumulatedReasonContent: undefined, + accumulatedReasoningDuration: undefined + } + } + + // Use the last message as the response + const response = lastCheckpointMessages[lastCheckpointMessages.length - 1] as AIMessageChunk + + // Replace messages array + messages.length = 0 + messages.push(...lastCheckpointMessages.slice(0, lastCheckpointMessages.length - 1)) + + // Track total tokens used throughout this process + let totalTokens = response.usage_metadata?.total_tokens || 0 + + if (!response.tool_calls || response.tool_calls.length === 0) { + const acc = (response.additional_kwargs?.reasoning_content as string) || undefined + const dur = + typeof response.additional_kwargs?.reasoning_duration === 'number' + ? response.additional_kwargs.reasoning_duration + : undefined + return { + response, + usedTools: [], + sourceDocuments: [], + artifacts: [], + totalTokens, + accumulatedReasonContent: acc, + accumulatedReasoningDuration: dur + } + } + + // Stream tool calls if available + if (sseStreamer) { + const formattedToolCalls = response.tool_calls.map((toolCall: any) => ({ + tool: toolCall.name || 'tool', + toolInput: toolCall.args, + toolOutput: '' + })) + sseStreamer.streamCalledToolsEvent(chatId, flatten(formattedToolCalls)) + } + + // Remove tool calls with no id + const toBeRemovedToolCalls = [] + for (let i = 0; i < response.tool_calls.length; i++) { + const toolCall = response.tool_calls[i] + if (!toolCall.id) { + toBeRemovedToolCalls.push(toolCall) + usedTools.push({ + tool: toolCall.name || 'tool', + toolInput: toolCall.args, + toolOutput: response.content + }) + } + } + + for (const toolCall of toBeRemovedToolCalls) { + response.tool_calls.splice(response.tool_calls.indexOf(toolCall), 1) + } + + // Add LLM response with tool calls to messages + messages.push(response) + + // Process each tool call + for (let i = 0; i < response.tool_calls.length; i++) { + const toolCall = response.tool_calls[i] + + const selectedTool = toolsInstance.find((tool) => tool.name === toolCall.name) + if (selectedTool) { + let parsedDocs + let parsedArtifacts + + const flowConfig = { + chatflowId: options.chatflowid, + sessionId: options.sessionId, + chatId: options.chatId, + input: input, + state: options.agentflowRuntime?.state + } + + if (humanInput.type === 'reject') { + messages.pop() + const toBeRemovedTool = toolsInstance.find((tool) => tool.name === toolCall.name) + if (toBeRemovedTool) { + toolsInstance = toolsInstance.filter((tool) => tool.name !== toolCall.name) + // Remove other tools with the same agentSelectedTool such as MCP tools + toolsInstance = toolsInstance.filter( + (tool) => (tool as any).agentSelectedTool !== (toBeRemovedTool as any).agentSelectedTool + ) + } + } + if (humanInput.type === 'proceed') { + // Intercept write_todos — handled by PlanningTool, not the LangChain tool + if (toolCall.name === 'write_todos' && planningTool) { + const toolOutput = planningTool.handleToolCall(toolCall.args as { todos: Todo[] }) + messages.push({ role: 'tool', content: toolOutput, tool_call_id: toolCall.id, name: toolCall.name }) + usedTools.push({ tool: 'write_todos', toolInput: toolCall.args, toolOutput }) + } else { + let toolIds: ICommonObject | undefined + if (options.analyticHandlers) { + toolIds = await options.analyticHandlers.onToolStart(toolCall.name, toolCall.args, options.parentTraceIds) + } + + try { + let toolOutput = await selectedTool.call( + toolCall.args, + { signal: abortController?.signal }, + //@ts-ignore + undefined, + //@ts-ignore + flowConfig + ) + + if (options.analyticHandlers && toolIds) { + await options.analyticHandlers.onToolEnd(toolIds, toolOutput) + } + + // Extract source documents if present + if (typeof toolOutput === 'string' && toolOutput.includes(SOURCE_DOCUMENTS_PREFIX)) { + const [output, docs] = toolOutput.split(SOURCE_DOCUMENTS_PREFIX) + toolOutput = output + try { + parsedDocs = JSON.parse(docs) + sourceDocuments.push(parsedDocs) + } catch (e) { + console.error('Error parsing source documents from tool:', e) + } + } + + // Extract artifacts if present + if (typeof toolOutput === 'string' && toolOutput.includes(ARTIFACTS_PREFIX)) { + const [output, artifact] = toolOutput.split(ARTIFACTS_PREFIX) + toolOutput = output + try { + parsedArtifacts = JSON.parse(artifact) + artifacts.push(parsedArtifacts) + } catch (e) { + console.error('Error parsing artifacts from tool:', e) + } + } + + let toolInput + if (typeof toolOutput === 'string' && toolOutput.includes(TOOL_ARGS_PREFIX)) { + const [output, args] = toolOutput.split(TOOL_ARGS_PREFIX) + toolOutput = output + try { + toolInput = JSON.parse(args) + } catch (e) { + console.error('Error parsing tool input from tool:', e) + } + } + + // Add tool message to conversation + messages.push({ + role: 'tool', + content: toolOutput, + tool_call_id: toolCall.id, + name: toolCall.name, + additional_kwargs: { + artifacts: parsedArtifacts, + sourceDocuments: parsedDocs + } + }) + + // Track used tools + usedTools.push({ + tool: toolCall.name, + toolInput: toolInput ?? toolCall.args, + toolOutput + }) + } catch (e) { + if (options.analyticHandlers && toolIds) { + await options.analyticHandlers.onToolEnd(toolIds, e) + } + + console.error('Error invoking tool:', e) + const errMsg = getErrorMessage(e) + let toolInput = toolCall.args + if (typeof errMsg === 'string' && errMsg.includes(TOOL_ARGS_PREFIX)) { + const [_, args] = errMsg.split(TOOL_ARGS_PREFIX) + try { + toolInput = JSON.parse(args) + } catch (e) { + console.error('Error parsing tool input from tool:', e) + } + } + + usedTools.push({ + tool: selectedTool.name, + toolInput, + toolOutput: '', + error: getErrorMessage(e) + }) + sseStreamer?.streamUsedToolsEvent(chatId, flatten(usedTools)) + throw new Error(getErrorMessage(e)) + } + } // close else (non-write_todos tools) + } + } + } + + // Return direct tool output if there's exactly one tool with returnDirect + if (response.tool_calls.length === 1) { + const selectedTool = toolsInstance.find((tool) => tool.name === response.tool_calls?.[0]?.name) + if (selectedTool && selectedTool.returnDirect) { + const lastToolOutput = usedTools[0]?.toolOutput || '' + const lastToolOutputString = typeof lastToolOutput === 'string' ? lastToolOutput : JSON.stringify(lastToolOutput, null, 2) + + if (sseStreamer && !isStructuredOutput) { + sseStreamer.streamTokenEvent(chatId, lastToolOutputString) + } + + const acc = (response.additional_kwargs?.reasoning_content as string) || undefined + const dur = + typeof response.additional_kwargs?.reasoning_duration === 'number' + ? response.additional_kwargs.reasoning_duration + : undefined + return { + response: new AIMessageChunk(lastToolOutputString), + usedTools, + sourceDocuments, + artifacts, + totalTokens, + accumulatedReasonContent: acc, + accumulatedReasoningDuration: dur + } + } + } + + // Get LLM response after tool calls + let newResponse: AIMessageChunk + + if (llmNodeInstance && (llmNodeInstance as any).builtInTools && (llmNodeInstance as any).builtInTools.length > 0) { + toolsInstance.push(...(llmNodeInstance as any).builtInTools) + } + + if (llmNodeInstance && toolsInstance.length > 0) { + if (llmNodeInstance.bindTools === undefined) { + throw new Error(`Agent needs to have a function calling capable models.`) + } + + // @ts-ignore + llmNodeInstance = llmNodeInstance.bindTools(toolsInstance) + } + + if (isStreamable) { + newResponse = await this.handleStreamingResponse( + sseStreamer, + llmNodeInstance, + messages, + chatId, + abortController, + isStructuredOutput, + isLastNode + ) + } else { + newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal }) + + // Stream non-streaming response if this is the last node + if (isLastNode && sseStreamer && !isStructuredOutput) { + sseStreamer.streamTokenEvent(chatId, extractResponseContent(newResponse)) + } + } + + // Add tokens from this response + if (newResponse.usage_metadata?.total_tokens) { + totalTokens += newResponse.usage_metadata.total_tokens + } + + // Accumulate reasoning and duration from checkpoint response and this turn + let accumulatedReasonContent = (response.additional_kwargs?.reasoning_content as string) || '' + if (newResponse.additional_kwargs?.reasoning_content) { + accumulatedReasonContent += + (accumulatedReasonContent ? '\n\n' : '') + (newResponse.additional_kwargs.reasoning_content as string) + } + let accumulatedReasoningDuration = + (typeof response.additional_kwargs?.reasoning_duration === 'number' ? response.additional_kwargs.reasoning_duration : 0) + + (typeof newResponse.additional_kwargs?.reasoning_duration === 'number' ? newResponse.additional_kwargs.reasoning_duration : 0) + + // Check for recursive tool calls and handle them + if (newResponse.tool_calls && newResponse.tool_calls.length > 0) { + const { + response: recursiveResponse, + usedTools: recursiveUsedTools, + sourceDocuments: recursiveSourceDocuments, + artifacts: recursiveArtifacts, + totalTokens: recursiveTokens, + isWaitingForHumanInput: recursiveIsWaitingForHumanInput, + accumulatedReasonContent: recursiveAccumulatedReasonContent, + accumulatedReasoningDuration: recursiveAccumulatedReasoningDuration + } = await this.handleToolCalls({ + response: newResponse, + messages, + toolsInstance, + sseStreamer, + chatId, + input, + options, + abortController, + llmNodeInstance, + isStreamable, + isLastNode, + iterationContext, + isStructuredOutput, + accumulatedReasonContent, + accumulatedReasoningDuration, + planningTool + }) + + // Merge results from recursive tool calls + newResponse = recursiveResponse + usedTools.push(...recursiveUsedTools) + sourceDocuments = [...sourceDocuments, ...recursiveSourceDocuments] + artifacts = [...artifacts, ...recursiveArtifacts] + totalTokens += recursiveTokens + isWaitingForHumanInput = recursiveIsWaitingForHumanInput + if (recursiveAccumulatedReasonContent !== undefined) { + accumulatedReasonContent = recursiveAccumulatedReasonContent + } + if (recursiveAccumulatedReasoningDuration !== undefined) { + accumulatedReasoningDuration = recursiveAccumulatedReasoningDuration + } + } + + return { + response: newResponse, + usedTools, + sourceDocuments, + artifacts, + totalTokens, + isWaitingForHumanInput, + accumulatedReasonContent: accumulatedReasonContent || undefined, + accumulatedReasoningDuration: accumulatedReasoningDuration || undefined + } + } + + /** + * Processes sandbox links in the response text and converts them to file annotations + */ + private async processSandboxLinks(text: string, baseURL: string, chatflowId: string, chatId: string): Promise { + let processedResponse = text + + // Regex to match sandbox links: [text](sandbox:/path/to/file) + const sandboxLinkRegex = /\[([^\]]+)\]\(sandbox:\/([^)]+)\)/g + const matches = Array.from(text.matchAll(sandboxLinkRegex)) + + for (const match of matches) { + const fullMatch = match[0] + const linkText = match[1] + const filePath = match[2] + + try { + // Extract and sanitize filename from the file path (LLM-generated, untrusted) + const fileName = sanitizeFileName(filePath) + + // Replace sandbox link with proper download URL + const downloadUrl = `${baseURL}/api/v1/get-upload-file?chatflowId=${chatflowId}&chatId=${chatId}&fileName=${fileName}&download=true` + const newLink = `[${linkText}](${downloadUrl})` + + processedResponse = processedResponse.replace(fullMatch, newLink) + } catch (error) { + console.error('Error processing sandbox link:', error) + // If there's an error, remove the sandbox link as fallback + processedResponse = processedResponse.replace(fullMatch, linkText) + } + } + + return processedResponse + } +} + +module.exports = { nodeClass: SmartAgent_Agentflow } diff --git a/packages/components/nodes/agentflow/SmartAgent/context/SystemPromptBuilder.ts b/packages/components/nodes/agentflow/SmartAgent/context/SystemPromptBuilder.ts new file mode 100644 index 00000000000..8de58d51e23 --- /dev/null +++ b/packages/components/nodes/agentflow/SmartAgent/context/SystemPromptBuilder.ts @@ -0,0 +1,169 @@ +// Part 1: Base agent prompt +const BASE_AGENT_PROMPT = `You are a smart and powerful AI Agent that helps users accomplish tasks using tools. You respond with text and tool calls. The user can see your responses and tool outputs in real time. + +## Core Behavior + +- Be concise and direct. Don't over-explain unless asked. +- NEVER add unnecessary preamble ("Sure!", "Great question!", "I'll now..."). +- Don't say "I'll now do X" — just do it. +- If the request is ambiguous, ask questions before acting. +- If asked how to approach something, explain first, then act. + +## Professional Objectivity + +- Prioritize accuracy over validating the user's beliefs +- Disagree respectfully when the user is incorrect +- Avoid unnecessary superlatives, praise, or emotional validation + +## Doing Tasks + +When the user asks you to do something: + +1. **Understand first** — read relevant files, check existing patterns. Quick but thorough — gather enough evidence to start, then iterate. +2. **Act** — implement the solution. Work quickly but accurately. +3. **Verify** — check your work against what was asked, not against your own output. Your first attempt is rarely correct — iterate. + +Keep working until the task is fully complete. Don't stop partway and explain what you would do — just do it. Only yield back to the user when the task is done or you're genuinely blocked. + +**When things go wrong:** +- If something fails repeatedly, stop and analyze *why* — don't keep retrying the same approach. +- If you're blocked, tell the user what's wrong and ask for guidance. + +## Progress Updates + +For longer tasks, provide brief progress updates at reasonable intervals — a concise sentence recapping what you've done and what's next.` + +// Part 3: Skills prompt +const SKILLS_PROMPT = `## Skills +// TODO: skills prompt — frontmatter list + load instructions` + +// Part 4: Filesystem tool prompt +const FILESYSTEM_TOOL_PROMPT = `## Filesystem Tools +// TODO: ls, read_file, write_file usage guidance` + +// Part 5: Subagent prompt +const SUBAGENT_PROMPT = `## Subagent Delegation +// TODO: task delegation guidance` + +// Part 6: Async subagent prompt +const ASYNC_SUBAGENT_PROMPT = `## Async Subagent +// TODO: async subagent delegation guidance` + +// Part 7: Wrap user system message / memory content with agent_memory + memory_guidelines tags +function buildMemoryPrompt(content: string): string { + return ` +${content} + + + +The above was loaded in from files in your filesystem. As you learn from your interactions with the user, you can save new knowledge by calling the \`edit_file\` tool. + +**Learning from feedback:** +- One of your MAIN PRIORITIES is to learn from your interactions with the user. These learnings can be implicit or explicit. This means that in the future, you will remember this important information. +- When you need to remember something, updating memory must be your FIRST, IMMEDIATE action - before responding to the user, before calling other tools, before doing anything else. Just update memory immediately. +- When user says something is better/worse, capture WHY and encode it as a pattern. +- Each correction is a chance to improve permanently - don't just fix the immediate issue, update your instructions. +- A great opportunity to update your memories is when the user interrupts a tool call and provides feedback. You should update your memories immediately before revising the tool call. +- Look for the underlying principle behind corrections, not just the specific mistake. +- The user might not explicitly ask you to remember something, but if they provide information that is useful for future use, you should update your memories immediately. + +**Asking for information:** +- If you lack context to perform an action (e.g. send a Slack DM, requires a user ID/email) you should explicitly ask the user for this information. +- It is preferred for you to ask for information, don't assume anything that you do not know! +- When the user provides information that is useful for future use, you should update your memories immediately. + +**When to update memories:** +- When the user explicitly asks you to remember something (e.g., "remember my email", "save this preference") +- When the user describes your role or how you should behave (e.g., "you are a web researcher", "always do X") +- When the user gives feedback on your work - capture what was wrong and how to improve +- When the user provides information required for tool use (e.g., slack channel ID, email addresses) +- When the user provides context useful for future tasks, such as how to use tools, or which actions to take in a particular situation +- When you discover new patterns or preferences (coding styles, conventions, workflows) + +**When to NOT update memories:** +- When the information is temporary or transient (e.g., "I'm running late", "I'm on my phone right now") +- When the information is a one-time task request (e.g., "Find me a recipe", "What's 25 * 4?") +- When the information is a simple question that doesn't reveal lasting preferences (e.g., "What day is it?", "Can you explain X?") +- When the information is an acknowledgment or small talk (e.g., "Sounds good!", "Hello", "Thanks for that") +- When the information is stale or irrelevant in future conversations +- Never store API keys, access tokens, passwords, or any other credentials in any file, memory, or system prompt. +- If the user asks where to put API keys or provides an API key, do NOT echo or save it. + +**Examples:** +Example 1 (remembering user information): +User: Can you connect to my google account? +Agent: Sure, I'll connect to your google account, what's your google account email? +User: john@example.com +Agent: Let me save this to my memory. +Tool Call: edit_file(...) -> remembers that the user's google account email is john@example.com + +Example 2 (remembering implicit user preferences): +User: Can you write me an example for creating a deep agent in LangChain? +Agent: Sure, I'll write you an example for creating a deep agent in LangChain +User: Can you do this in JavaScript +Agent: Let me save this to my memory. +Tool Call: edit_file(...) -> remembers that the user prefers to get LangChain code examples in JavaScript +Agent: Sure, here is the JavaScript example + +Example 3 (do not remember transient information): +User: I'm going to play basketball tonight so I will be offline for a few hours. +Agent: Okay I'll add a block to your calendar. +Tool Call: create_calendar_event(...) -> just calls a tool, does not commit anything to memory, as it is transient information +` +} + +export interface SystemPromptOptions { + todoListPrompt: string // Part 2: from PlanningTool + skillsEnabled?: boolean // Part 3 + filesystemEnabled?: boolean // Part 4 + subagentEnabled?: boolean // Part 5 + asyncSubagentEnabled?: boolean // Part 6 + userSystemPrompt?: string // Part 7: user-specified system message / memory (AGENTS.md) +} + +/** + * Assembles the system prompt in a fixed order: + * 1. BASE_AGENT_PROMPT (always) + * 2. Todo list prompt (always) + * 3. Skills prompt (if configured) + * 4. Filesystem tool prompt + * 5. Subagent prompt + * 6. Async subagent prompt (if configured) + * 7. User specified system message / Memory prompt ( + ) + */ +export function buildSystemPrompt(opts: SystemPromptOptions): string { + const parts: string[] = [] + + // Part 1: Base agent prompt (always) + parts.push(BASE_AGENT_PROMPT) + + // Part 2: Todo list prompt (always) + parts.push(opts.todoListPrompt) + + // Part 3: Skills — if configured + if (opts.skillsEnabled) { + parts.push(SKILLS_PROMPT) + } + + // Part 4: Filesystem tool prompt + if (opts.filesystemEnabled) { + parts.push(FILESYSTEM_TOOL_PROMPT) + } + + // Part 5: Subagent prompt + if (opts.subagentEnabled) { + parts.push(SUBAGENT_PROMPT) + } + + // Part 6: Async subagent prompt — if configured + if (opts.asyncSubagentEnabled) { + parts.push(ASYNC_SUBAGENT_PROMPT) + } + + // Part 7: User specified system message / Memory prompt / AGENT.md — if content exists + if (opts.userSystemPrompt) { + parts.push(buildMemoryPrompt(opts.userSystemPrompt)) + } + + return parts.join('\n\n') +} diff --git a/packages/components/nodes/agentflow/SmartAgent/planning/PlanningTool.ts b/packages/components/nodes/agentflow/SmartAgent/planning/PlanningTool.ts new file mode 100644 index 00000000000..d249e94a662 --- /dev/null +++ b/packages/components/nodes/agentflow/SmartAgent/planning/PlanningTool.ts @@ -0,0 +1,142 @@ +export type TodoStatus = 'pending' | 'in_progress' | 'completed' + +export interface Todo { + content: string + status: TodoStatus +} + +const WRITE_TODOS_DESCRIPTION = `Use this tool to create and manage a structured task list for your current work session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. +It also helps the user understand the progress of the task and overall progress of their requests. +Only use this tool if you think it will be helpful in staying organized. If the user's request is trivial and takes less than 3 steps, it is better to NOT use this tool and just do the task directly. + +## When to Use This Tool +Use this tool in these scenarios: + +1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions +2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations +3. User explicitly requests todo list - When the user directly asks you to use the todo list +4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) +5. The plan may need future revisions or updates based on results from the first few steps. Keeping track of this in a list is helpful. + +## How to Use This Tool +1. When you start working on a task - Mark it as in_progress BEFORE beginning work. +2. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation. +3. You can also update future tasks, such as deleting them if they are no longer necessary, or adding new tasks that are necessary. Don't change previously completed tasks. +4. You can make several updates to the todo list at once. For example, when you complete a task, you can mark the next task you need to start as in_progress. + +## When NOT to Use This Tool +It is important to skip using this tool when: +1. There is only a single, straightforward task +2. The task is trivial and tracking it provides no benefit +3. The task can be completed in less than 3 trivial steps +4. The task is purely conversational or informational + +## Task States and Management + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (you can have multiple tasks in_progress at a time if they are not related to each other and can be run in parallel) + - completed: Task finished successfully + +2. **Task Management**: + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Complete current tasks before starting new ones + - Remove tasks that are no longer relevant from the list entirely + - IMPORTANT: When you write this todo list, you should mark your first task (or tasks) as in_progress immediately!. + - IMPORTANT: Unless all tasks are completed, you should always have at least one task in_progress to show the user that you are working on something. + +3. **Task Completion Requirements**: + - ONLY mark a task as completed when you have FULLY accomplished it + - If you encounter errors, blockers, or cannot finish, keep the task as in_progress + - When blocked, create a new task describing what needs to be resolved + - Never mark a task as completed if: + - There are unresolved issues or errors + - Work is partial or incomplete + - You encountered blockers that prevent completion + - You couldn't find necessary resources or dependencies + - Quality standards haven't been met + +4. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully +Remember: If you only need to make a few tool calls to complete a task, and it is clear what you need to do, it is better to just do the task directly and NOT call this tool at all.` + +const TODO_SYSTEM_PROMPT = `## \`write_todos\` + +You have access to the \`write_todos\` tool to help you manage and plan complex objectives. +Use this tool for complex objectives to ensure that you are tracking each necessary step and giving the user visibility into your progress. +This tool is very helpful for planning complex objectives, and for breaking down these larger complex objectives into smaller steps. + +It is critical that you mark todos as completed as soon as you are done with a step. Do not batch up multiple steps before marking them as completed. +For simple objectives that only require a few steps, it is better to just complete the objective directly and NOT use this tool. +Writing todos takes time and tokens, use it when it is helpful for managing complex many-step problems! But not for simple few-step requests. + +## Important To-Do List Usage Notes to Remember +- The \`write_todos\` tool should never be called multiple times in parallel. +- Don't be afraid to revise the To-Do list as you go. New information may reveal new tasks that need to be done, or old tasks that are irrelevant.` + +export interface PlanningToolOptions { + onUpdate?: (todos: Todo[]) => void +} + +export class PlanningTool { + #todos: Todo[] = [] + onUpdate?: (todos: Todo[]) => void + + constructor(options?: PlanningToolOptions) { + this.onUpdate = options?.onUpdate + } + + get todos(): Todo[] { + return this.#todos + } + + get toolDefinition(): { name: string; description: string; parameters: Record } { + return { + name: 'write_todos', + description: WRITE_TODOS_DESCRIPTION, + parameters: { + type: 'object', + properties: { + todos: { + type: 'array', + description: 'List of todo items to update', + items: { + type: 'object', + properties: { + content: { type: 'string', description: 'Content of the todo item' }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'completed'], + description: 'Status of the todo' + } + }, + required: ['content', 'status'] + } + } + }, + required: ['todos'] + } + } + } + + getSystemPrompt(): string { + return TODO_SYSTEM_PROMPT + } + + handleToolCall(args: { todos: Todo[] }): string { + this.#todos = args.todos + if (this.onUpdate) { + this.onUpdate(this.#todos) + } + return `Updated todo list to ${JSON.stringify(this.#todos)}` + } + + load(todos: Todo[]): void { + this.#todos = todos + } +} diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts index 833e3b7c2eb..e92bc0a39fd 100644 --- a/packages/components/nodes/agentflow/Start/Start.ts +++ b/packages/components/nodes/agentflow/Start/Start.ts @@ -39,7 +39,8 @@ class Start_Agentflow implements INode { { label: 'Form Input', name: 'formInput', - description: 'Start the workflow with form inputs' + description: 'Start the workflow with form inputs', + client: ['agentflowv2'] } ], default: 'chatInput' diff --git a/packages/components/nodes/agentflow/utils.ts b/packages/components/nodes/agentflow/utils.ts index 59e3fb569df..4a80677af1e 100644 --- a/packages/components/nodes/agentflow/utils.ts +++ b/packages/components/nodes/agentflow/utils.ts @@ -197,6 +197,49 @@ export const revertBase64ImagesToFileRefs = (messages: BaseMessageLike[]): BaseM return updatedMessages } +// ─── Normalizing messages for DB storage/UI rendering ──────────────────────── + +/** + * Converts LangChain message/chunk instances into plain JSON objects for clean DB storage. + * This avoids persisting large `{ lc, type, kwargs }` blobs and keeps execution-details UI readable. + */ +export const normalizeMessagesForStorage = (messages: BaseMessageLike[]): BaseMessageLike[] => { + return (messages || []).map((msg: any) => { + if (msg?.lc_namespace || typeof msg?._getType === 'function') { + const rawType = typeof msg?._getType === 'function' ? msg._getType() : msg?.type + const role = + rawType === 'ai' + ? 'assistant' + : rawType === 'human' + ? 'user' + : rawType === 'system' + ? 'system' + : rawType === 'tool' + ? 'tool' + : msg?.role || 'assistant' + + const plain: Record = { + role, + content: msg?.content ?? '' + } + + if (msg?.name) plain.name = msg.name + if (msg?.tool_call_id) plain.tool_call_id = msg.tool_call_id + if (Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0) plain.tool_calls = msg.tool_calls + + if (msg?.additional_kwargs && Object.keys(msg.additional_kwargs).length > 0) { + plain.additional_kwargs = msg.additional_kwargs + } + + if (msg?.usage_metadata) plain.usage_metadata = msg.usage_metadata + if (msg?.id) plain.id = msg.id + + return plain + } + return msg + }) +} + // ─── Handling new image uploads ────────────────────────────────────────────── /** diff --git a/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts b/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts index 314b5421f06..2643dac55ae 100644 --- a/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts +++ b/packages/components/nodes/agents/OpenAIAssistant/OpenAIAssistant.ts @@ -45,7 +45,7 @@ class OpenAIAssistant_Agents implements INode { this.icon = 'assistant.svg' this.description = `An agent that uses OpenAI Assistant API to pick the tool and args to call` this.badge = 'DEPRECATING' - this.deprecateMessage = 'OpenAI Assistant is deprecated and will be removed in a future release. Use Custom Assistant instead.' + this.deprecateMessage = 'OpenAI Assistant is deprecated and will be removed in a future release. Use Agent instead.' this.baseClasses = [this.type] this.inputs = [ { diff --git a/packages/components/nodes/chatmodels/ChatBaiduWenxin/ChatBaiduWenxin.test.ts b/packages/components/nodes/chatmodels/ChatBaiduWenxin/ChatBaiduWenxin.test.ts new file mode 100644 index 00000000000..eb8ce6913ea --- /dev/null +++ b/packages/components/nodes/chatmodels/ChatBaiduWenxin/ChatBaiduWenxin.test.ts @@ -0,0 +1,72 @@ +jest.mock('@langchain/baidu-qianfan', () => ({ + ChatBaiduQianfan: jest.fn().mockImplementation((fields) => ({ fields })) +})) + +jest.mock('../../../src/utils', () => ({ + getBaseClasses: jest.fn().mockReturnValue(['BaseChatModel']), + getCredentialData: jest.fn(), + getCredentialParam: jest.fn() +})) + +jest.mock('../../../src/modelLoader', () => ({ + MODEL_TYPE: { CHAT: 'chat' }, + getModels: jest.fn() +})) + +import { getCredentialData, getCredentialParam } from '../../../src/utils' +import { getModels } from '../../../src/modelLoader' + +const { nodeClass: ChatBaiduWenxin } = require('./ChatBaiduWenxin') + +describe('ChatBaiduWenxin', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('loads model options from the shared model loader', async () => { + ;(getModels as jest.Mock).mockResolvedValue([{ label: 'ernie-4.5-8k-preview', name: 'ernie-4.5-8k-preview' }]) + + const node = new ChatBaiduWenxin() + const models = await node.loadMethods.listModels() + + expect(getModels).toHaveBeenCalledWith('chat', 'chatBaiduWenxin') + expect(models).toEqual([{ label: 'ernie-4.5-8k-preview', name: 'ernie-4.5-8k-preview' }]) + }) + + it('passes advanced settings and custom model names to ChatBaiduQianfan', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ + qianfanAccessKey: 'access-key', + qianfanSecretKey: 'secret-key' + }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new ChatBaiduWenxin() + const model = await node.init( + { + credential: 'cred-1', + inputs: { + modelName: 'ernie-4.0-8k', + customModelName: 'ernie-speed-128k', + temperature: '0.2', + streaming: false, + topP: '0.8', + penaltyScore: '1.4', + userId: 'user-123' + } + }, + '', + {} + ) + + expect(model.fields).toMatchObject({ + qianfanAccessKey: 'access-key', + qianfanSecretKey: 'secret-key', + modelName: 'ernie-speed-128k', + temperature: 0.2, + streaming: false, + topP: 0.8, + penaltyScore: 1.4, + userId: 'user-123' + }) + }) +}) diff --git a/packages/components/nodes/chatmodels/ChatBaiduWenxin/ChatBaiduWenxin.ts b/packages/components/nodes/chatmodels/ChatBaiduWenxin/ChatBaiduWenxin.ts index 01517144d0d..66cf17efff6 100644 --- a/packages/components/nodes/chatmodels/ChatBaiduWenxin/ChatBaiduWenxin.ts +++ b/packages/components/nodes/chatmodels/ChatBaiduWenxin/ChatBaiduWenxin.ts @@ -1,6 +1,7 @@ import { BaseCache } from '@langchain/core/caches' import { ChatBaiduQianfan } from '@langchain/baidu-qianfan' -import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +import { ICommonObject, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../src/Interface' +import { MODEL_TYPE, getModels } from '../../../src/modelLoader' import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' class ChatBaiduWenxin_ChatModels implements INode { @@ -18,7 +19,7 @@ class ChatBaiduWenxin_ChatModels implements INode { constructor() { this.label = 'Baidu Wenxin' this.name = 'chatBaiduWenxin' - this.version = 2.0 + this.version = 3.0 this.type = 'ChatBaiduWenxin' this.icon = 'baiduwenxin.svg' this.category = 'Chat Models' @@ -38,10 +39,20 @@ class ChatBaiduWenxin_ChatModels implements INode { optional: true }, { - label: 'Model', + label: 'Model Name', name: 'modelName', + type: 'asyncOptions', + loadMethod: 'listModels', + default: 'ernie-4.5-8k-preview' + }, + { + label: 'Custom Model Name', + name: 'customModelName', type: 'string', - placeholder: 'ERNIE-Bot-turbo' + placeholder: 'ernie-speed-128k', + description: 'Custom model name to use. If provided, it will override the selected model.', + additionalParams: true, + optional: true }, { label: 'Temperature', @@ -57,15 +68,52 @@ class ChatBaiduWenxin_ChatModels implements INode { type: 'boolean', default: true, optional: true + }, + { + label: 'Top Probability', + name: 'topP', + type: 'number', + description: 'Nucleus sampling. The model considers tokens whose cumulative probability mass reaches this value.', + step: 0.1, + optional: true, + additionalParams: true + }, + { + label: 'Penalty Score', + name: 'penaltyScore', + type: 'number', + description: 'Penalizes repeated tokens according to frequency. Baidu Qianfan accepts values from 1.0 to 2.0.', + step: 0.1, + optional: true, + additionalParams: true + }, + { + label: 'User ID', + name: 'userId', + type: 'string', + description: 'Optional unique identifier for the end user making the request.', + optional: true, + additionalParams: true } ] } + //@ts-ignore + loadMethods = { + async listModels(): Promise { + return await getModels(MODEL_TYPE.CHAT, 'chatBaiduWenxin') + } + } + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const cache = nodeData.inputs?.cache as BaseCache const temperature = nodeData.inputs?.temperature as string const modelName = nodeData.inputs?.modelName as string + const customModelName = nodeData.inputs?.customModelName as string const streaming = nodeData.inputs?.streaming as boolean + const topP = nodeData.inputs?.topP as string + const penaltyScore = nodeData.inputs?.penaltyScore as string + const userId = nodeData.inputs?.userId as string const credentialData = await getCredentialData(nodeData.credential ?? '', options) const qianfanAccessKey = getCredentialParam('qianfanAccessKey', credentialData, nodeData) @@ -75,9 +123,12 @@ class ChatBaiduWenxin_ChatModels implements INode { streaming: streaming ?? true, qianfanAccessKey, qianfanSecretKey, - modelName, + modelName: customModelName || modelName, temperature: temperature ? parseFloat(temperature) : undefined } + if (topP) obj.topP = parseFloat(topP) + if (penaltyScore) obj.penaltyScore = parseFloat(penaltyScore) + if (userId) obj.userId = userId if (cache) obj.cache = cache const model = new ChatBaiduQianfan(obj) diff --git a/packages/components/nodes/documentloaders/Notion/NotionDB.ts b/packages/components/nodes/documentloaders/Notion/NotionDB.ts index ed8103185c4..5dfa86ffdd8 100644 --- a/packages/components/nodes/documentloaders/Notion/NotionDB.ts +++ b/packages/components/nodes/documentloaders/Notion/NotionDB.ts @@ -3,6 +3,7 @@ import { ICommonObject, IDocument, INode, INodeData, INodeParams } from '../../. import { TextSplitter } from '@langchain/textsplitters' import { NotionAPILoader, NotionAPILoaderOptions } from '@langchain/community/document_loaders/web/notionapi' import { getCredentialData, getCredentialParam, handleEscapeCharacters, INodeOutputsValue } from '../../../src' +import { applyCompactTableTransformer } from './notionTableFix' class NotionDB_DocumentLoaders implements INode { label: string @@ -108,6 +109,7 @@ class NotionDB_DocumentLoaders implements INode { type: 'database' } const loader = new NotionAPILoader(obj) + applyCompactTableTransformer(loader) let docs: IDocument[] = [] if (textSplitter) { diff --git a/packages/components/nodes/documentloaders/Notion/NotionPage.ts b/packages/components/nodes/documentloaders/Notion/NotionPage.ts index 83683306e37..f6b115bf036 100644 --- a/packages/components/nodes/documentloaders/Notion/NotionPage.ts +++ b/packages/components/nodes/documentloaders/Notion/NotionPage.ts @@ -3,6 +3,7 @@ import { ICommonObject, IDocument, INode, INodeData, INodeParams } from '../../. import { TextSplitter } from '@langchain/textsplitters' import { NotionAPILoader, NotionAPILoaderOptions } from '@langchain/community/document_loaders/web/notionapi' import { getCredentialData, getCredentialParam, handleEscapeCharacters, INodeOutputsValue } from '../../../src' +import { applyCompactTableTransformer } from './notionTableFix' class NotionPage_DocumentLoaders implements INode { label: string @@ -105,6 +106,7 @@ class NotionPage_DocumentLoaders implements INode { type: 'page' } const loader = new NotionAPILoader(obj) + applyCompactTableTransformer(loader) let docs: IDocument[] = [] if (textSplitter) { diff --git a/packages/components/nodes/documentloaders/Notion/notionTableFix.test.ts b/packages/components/nodes/documentloaders/Notion/notionTableFix.test.ts new file mode 100644 index 00000000000..8ae15fc3961 --- /dev/null +++ b/packages/components/nodes/documentloaders/Notion/notionTableFix.test.ts @@ -0,0 +1,251 @@ +import { applyCompactTableTransformer } from './notionTableFix' + +/** + * Creates a mock NotionAPILoader with fake n2mClient and notionClient. + * The n2mClient.blockToMarkdown mock converts rich_text cells to their plain_text, + * mimicking the real notion-to-md behavior (which appends trailing newlines). + */ +function createMockLoader(tableRows: MockTableRow[]) { + let capturedTransformer: ((block: Record) => Promise) | null = null + + const mockNotionClient = { + blocks: { + children: { + list: jest.fn().mockResolvedValue({ + results: tableRows.map((row) => ({ + type: 'table_row', + table_row: { cells: row.cells } + })), + has_more: false, + next_cursor: null + }) + } + } + } + + const mockN2m = { + setCustomTransformer: jest.fn((type: string, transformer: typeof capturedTransformer) => { + if (type === 'table') { + capturedTransformer = transformer + } + }), + blockToMarkdown: jest.fn(async (block: { paragraph: { rich_text: MockRichText[] } }) => { + // Simulate notion-to-md behavior: join plain_text with trailing newlines + const text = block.paragraph.rich_text.map((rt: MockRichText) => rt.plain_text).join('') + return text + '\n\n' + }) + } + + const loader = { + n2mClient: mockN2m, + notionClient: mockNotionClient + } + + return { + loader, + mockNotionClient, + mockN2m, + getTransformer: () => capturedTransformer + } +} + +interface MockRichText { + type: string + plain_text: string +} + +interface MockTableRow { + cells: MockRichText[][] +} + +function richText(text: string): MockRichText[] { + return [{ type: 'text', plain_text: text }] +} + +function createTableBlock(options: { has_children: boolean; has_column_header: boolean }) { + return { + id: 'block-id', + type: 'table', + has_children: options.has_children, + table: { + has_column_header: options.has_column_header, + has_row_header: false, + table_width: 3 + } + } +} + +describe('applyCompactTableTransformer', () => { + it('registers a custom transformer for the table block type', () => { + const { loader, mockN2m } = createMockLoader([]) + applyCompactTableTransformer(loader as never) + expect(mockN2m.setCustomTransformer).toHaveBeenCalledWith('table', expect.any(Function)) + }) + + it('returns empty string when block has no children', async () => { + const { loader, getTransformer } = createMockLoader([]) + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: false, has_column_header: true })) + expect(result).toBe('') + }) + + it('returns empty string when API returns no rows', async () => { + const { loader, getTransformer } = createMockLoader([]) + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + expect(result).toBe('') + }) + + it('produces a compact markdown table with column header', async () => { + const rows: MockTableRow[] = [ + { cells: [richText('Item'), richText('Price'), richText('Stock')] }, + { cells: [richText('Apple'), richText('$1.00'), richText('50')] }, + { cells: [richText('Banana'), richText('$0.50'), richText('100')] } + ] + const { loader, getTransformer } = createMockLoader(rows) + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + expect(result).toBe( + '| Item | Price | Stock |\n' + '| --- | --- | --- |\n' + '| Apple | $1.00 | 50 |\n' + '| Banana | $0.50 | 100 |' + ) + }) + + it('generates blank header row when has_column_header is false', async () => { + const rows: MockTableRow[] = [{ cells: [richText('Apple'), richText('$1.00')] }, { cells: [richText('Banana'), richText('$0.50')] }] + const { loader, getTransformer } = createMockLoader(rows) + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: false })) + + expect(result).toBe('| | |\n' + '| --- | --- |\n' + '| Apple | $1.00 |\n' + '| Banana | $0.50 |') + }) + + it('trims trailing newlines from blockToMarkdown output', async () => { + const rows: MockTableRow[] = [{ cells: [richText('Header')] }, { cells: [richText('Value')] }] + const { loader, getTransformer, mockN2m } = createMockLoader(rows) + + // Override to add extra newlines as notion-to-md does + mockN2m.blockToMarkdown.mockImplementation(async (block: { paragraph: { rich_text: MockRichText[] } }) => { + const text = block.paragraph.rich_text.map((rt: MockRichText) => rt.plain_text).join('') + return text + '\n\n\n' + }) + + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + expect(result).toBe('| Header |\n| --- |\n| Value |') + }) + + it('escapes pipe characters in cell content', async () => { + const rows: MockTableRow[] = [{ cells: [richText('Header')] }, { cells: [richText('a|b|c')] }] + const { loader, getTransformer } = createMockLoader(rows) + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + expect(result).toBe('| Header |\n| --- |\n| a\\|b\\|c |') + }) + + it('escapes backslash characters in cell content', async () => { + const rows: MockTableRow[] = [{ cells: [richText('Path')] }, { cells: [richText('C:\\Users\\file')] }] + const { loader, getTransformer } = createMockLoader(rows) + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + expect(result).toBe('| Path |\n| --- |\n| C:\\\\Users\\\\file |') + }) + + it('replaces internal newlines with spaces', async () => { + const rows: MockTableRow[] = [{ cells: [richText('Header')] }, { cells: [richText('line1')] }] + const { loader, getTransformer, mockN2m } = createMockLoader(rows) + + mockN2m.blockToMarkdown.mockImplementation(async (block: { paragraph: { rich_text: MockRichText[] } }) => { + const text = block.paragraph.rich_text.map((rt: MockRichText) => rt.plain_text).join('') + // Simulate content with internal newlines + return text === 'line1' ? 'line1\nline2\n\n' : text + '\n\n' + }) + + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + expect(result).toBe('| Header |\n| --- |\n| line1 line2 |') + }) + + it('handles tables with long URLs without adding padding', async () => { + const rows: MockTableRow[] = [ + { cells: [richText('Item'), richText('URL')] }, + { cells: [richText('Chatflows'), richText('https://flowiseai.com')] }, + { cells: [richText('Docs'), richText('https://github.com/FlowiseAI/Flowise')] } + ] + const { loader, getTransformer } = createMockLoader(rows) + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + // Verify no extra spaces — each cell should be trimmed tightly + const lines = result.split('\n') + expect(lines[0]).toBe('| Item | URL |') + expect(lines[1]).toBe('| --- | --- |') + expect(lines[2]).toBe('| Chatflows | https://flowiseai.com |') + expect(lines[3]).toBe('| Docs | https://github.com/FlowiseAI/Flowise |') + }) + + it('falls back to default handler when an error occurs', async () => { + const { loader, getTransformer, mockNotionClient } = createMockLoader([]) + + // Simulate an API error + mockNotionClient.blocks.children.list.mockRejectedValueOnce(new Error('API rate limited')) + + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + // Returning false tells notion-to-md to use the default table handler + expect(result).toBe(false) + }) + + it('handles pagination when fetching child blocks', async () => { + const page1Rows: MockTableRow[] = [{ cells: [richText('Header')] }] + const page2Rows: MockTableRow[] = [{ cells: [richText('Row1')] }] + + const { loader, getTransformer, mockNotionClient } = createMockLoader([]) + + // Override to simulate paginated responses + mockNotionClient.blocks.children.list + .mockResolvedValueOnce({ + results: [{ type: 'table_row', table_row: { cells: page1Rows[0].cells } }], + has_more: true, + next_cursor: 'cursor-abc' + }) + .mockResolvedValueOnce({ + results: [{ type: 'table_row', table_row: { cells: page2Rows[0].cells } }], + has_more: false, + next_cursor: null + }) + + applyCompactTableTransformer(loader as never) + + const transformer = getTransformer()! + const result = await transformer(createTableBlock({ has_children: true, has_column_header: true })) + + expect(mockNotionClient.blocks.children.list).toHaveBeenCalledTimes(2) + expect(result).toBe('| Header |\n| --- |\n| Row1 |') + }) +}) diff --git a/packages/components/nodes/documentloaders/Notion/notionTableFix.ts b/packages/components/nodes/documentloaders/Notion/notionTableFix.ts new file mode 100644 index 00000000000..30e47262abf --- /dev/null +++ b/packages/components/nodes/documentloaders/Notion/notionTableFix.ts @@ -0,0 +1,145 @@ +import type { Client } from '@notionhq/client' +import type { NotionToMarkdown } from 'notion-to-md' +import type { NotionAPILoader } from '@langchain/community/document_loaders/web/notionapi' + +// ──────────────────────────────────────────────────────────────── +// Local type definitions for the Notion API shapes we use. +// These types are only available via @notionhq/client/build/src/api-endpoints +// which is an internal path, so we define the minimal shapes here. +// ──────────────────────────────────────────────────────────────── + +interface TableBlock { + id: string + has_children: boolean + table: { + has_column_header: boolean + } +} + +interface TableRowBlock { + type: 'table_row' + table_row: { + cells: RichText[][] + } +} + +interface RichText { + type: string + plain_text: string + annotations: Record + href: string | null +} + +interface BlockListResponse { + results: Record[] + has_more: boolean + next_cursor: string | null +} + +/** + * Provides typed access to the private fields of NotionAPILoader + * that we need for applying the custom table transformer. + */ +interface NotionAPILoaderInternal { + n2mClient: NotionToMarkdown + notionClient: Client +} + +/** + * Overrides the default table block handler on a NotionAPILoader instance + * to produce compact markdown tables without the excessive cell padding + * added by the markdown-table library's default options. + */ +export function applyCompactTableTransformer(loader: NotionAPILoader): void { + const internal = loader as unknown as NotionAPILoaderInternal + const n2m = internal.n2mClient + const notionClient = internal.notionClient + + n2m.setCustomTransformer('table', async (block) => { + try { + const tableBlock = block as unknown as TableBlock + const { id, has_children } = tableBlock + const { has_column_header } = tableBlock.table + + if (!has_children) return '' + + // Fetch all table row blocks using the public Notion API + const childBlocks = await fetchAllChildBlocks(notionClient, id) + + // Convert each row's cells to markdown strings + const tableArr: string[][] = [] + for (const child of childBlocks) { + const row = child as unknown as TableRowBlock + const cells: RichText[][] = row.table_row?.cells || [] + const cellStrings: string[] = [] + + for (const cell of cells) { + const raw = await n2m.blockToMarkdown({ + type: 'paragraph', + paragraph: { rich_text: cell } + } as Parameters[0]) + const cleaned = escapeForTable(raw) + cellStrings.push(cleaned) + } + + tableArr.push(cellStrings) + } + + if (tableArr.length === 0) return '' + + // Build the markdown table + const columnCount = tableArr[0].length + const headerArray = has_column_header ? tableArr[0] : new Array(columnCount).fill('') + const dataRows = has_column_header ? tableArr.slice(1) : tableArr + + const header = formatRow(headerArray) + const separator = formatRow(new Array(columnCount).fill('---')) + const rows = dataRows.map(formatRow) + + return [header, separator, ...rows].join('\n') + } catch { + // Fall back to the default (padded) table handler rather than failing the entire load + return false + } + }) +} + +/** + * Fetches all child blocks for a given block ID, handling pagination. + */ +async function fetchAllChildBlocks(notionClient: Client, blockId: string): Promise { + const blocks: BlockListResponse['results'] = [] + let cursor: string | undefined = undefined + + do { + const response: BlockListResponse = await notionClient.blocks.children.list({ + block_id: blockId, + start_cursor: cursor + }) + blocks.push(...response.results) + cursor = response.has_more ? response.next_cursor ?? undefined : undefined + } while (cursor) + + return blocks +} + +/** + * Cleans a blockToMarkdown result for use inside a table cell: + * - Trims surrounding whitespace and trailing newlines + * - Replaces internal newlines with spaces + * - Escapes backslashes and pipes to preserve table structure + */ +function escapeForTable(raw: string): string { + const trimmed = raw.trim() + const singleLine = trimmed.replace(/\n/g, ' ') + const escapedBackslashes = singleLine.replace(/\\/g, '\\\\') + const escapedPipes = escapedBackslashes.replace(/\|/g, '\\|') + return escapedPipes +} + +/** + * Formats an array of cell strings into a markdown table row. + */ +function formatRow(cells: string[]): string { + return '| ' + cells.join(' | ') + ' |' +} diff --git a/packages/components/nodes/embeddings/BaiduQianfanEmbedding/BaiduQianfanEmbedding.test.ts b/packages/components/nodes/embeddings/BaiduQianfanEmbedding/BaiduQianfanEmbedding.test.ts new file mode 100644 index 00000000000..501f8ca2dc3 --- /dev/null +++ b/packages/components/nodes/embeddings/BaiduQianfanEmbedding/BaiduQianfanEmbedding.test.ts @@ -0,0 +1,96 @@ +jest.mock('@langchain/baidu-qianfan', () => ({ + BaiduQianfanEmbeddings: jest.fn().mockImplementation((fields) => ({ fields })) +})) + +jest.mock('../../../src/utils', () => ({ + getBaseClasses: jest.fn().mockReturnValue(['Embeddings']), + getCredentialData: jest.fn(), + getCredentialParam: jest.fn() +})) + +jest.mock('../../../src/modelLoader', () => ({ + MODEL_TYPE: { EMBEDDING: 'embedding' }, + getModels: jest.fn() +})) + +import { getCredentialData, getCredentialParam } from '../../../src/utils' +import { getModels } from '../../../src/modelLoader' + +const { nodeClass: BaiduQianfanEmbedding } = require('./BaiduQianfanEmbedding') + +describe('BaiduQianfanEmbedding', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('loads embedding model options from the shared model loader', async () => { + ;(getModels as jest.Mock).mockResolvedValue([{ label: 'Embedding-V1', name: 'Embedding-V1' }]) + + const node = new BaiduQianfanEmbedding() + const models = await node.loadMethods.listModels() + + expect(getModels).toHaveBeenCalledWith('embedding', 'baiduQianfanEmbeddings') + expect(models).toEqual([{ label: 'Embedding-V1', name: 'Embedding-V1' }]) + }) + + it('maps credential, custom model names, and optional embedding parameters into BaiduQianfanEmbeddings', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ + qianfanAccessKey: 'access-key', + qianfanSecretKey: 'secret-key' + }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new BaiduQianfanEmbedding() + const model = await node.init( + { + credential: 'cred-1', + inputs: { + modelName: 'bge-large-zh', + customModelName: 'Qwen3-Embedding-4B', + stripNewLines: true, + batchSize: '8', + timeout: '15000' + } + }, + '', + {} + ) + + expect(model.fields).toMatchObject({ + modelName: 'Qwen3-Embedding-4B', + qianfanAccessKey: 'access-key', + qianfanSecretKey: 'secret-key', + stripNewLines: true, + batchSize: 8, + timeout: 15000 + }) + }) + + it('preserves explicit zero values for numeric parameters', async () => { + ;(getCredentialData as jest.Mock).mockResolvedValue({ + qianfanAccessKey: 'access-key', + qianfanSecretKey: 'secret-key' + }) + ;(getCredentialParam as jest.Mock).mockImplementation((key, credentialData) => credentialData[key]) + + const node = new BaiduQianfanEmbedding() + const model = await node.init( + { + credential: 'cred-1', + inputs: { + modelName: 'Embedding-V1', + batchSize: '0', + timeout: '0' + } + }, + '', + {} + ) + + expect(model.fields).toMatchObject({ + modelName: 'Embedding-V1', + batchSize: 0, + timeout: 0 + }) + }) +}) diff --git a/packages/components/nodes/embeddings/BaiduQianfanEmbedding/BaiduQianfanEmbedding.ts b/packages/components/nodes/embeddings/BaiduQianfanEmbedding/BaiduQianfanEmbedding.ts new file mode 100644 index 00000000000..03e824d9883 --- /dev/null +++ b/packages/components/nodes/embeddings/BaiduQianfanEmbedding/BaiduQianfanEmbedding.ts @@ -0,0 +1,116 @@ +import { BaiduQianfanEmbeddings, BaiduQianfanEmbeddingsParams } from '@langchain/baidu-qianfan' +import { ICommonObject, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../src/Interface' +import { MODEL_TYPE, getModels } from '../../../src/modelLoader' +import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' + +class BaiduQianfanEmbedding_Embeddings implements INode { + label: string + name: string + version: number + type: string + icon: string + category: string + description: string + baseClasses: string[] + credential: INodeParams + inputs: INodeParams[] + + constructor() { + this.label = 'Baidu Qianfan Embedding' + this.name = 'baiduQianfanEmbeddings' + this.version = 1.0 + this.type = 'BaiduQianfanEmbeddings' + this.icon = 'baiduwenxin.svg' + this.category = 'Embeddings' + this.description = 'Baidu Qianfan API to generate embeddings for a given text' + this.baseClasses = [this.type, ...getBaseClasses(BaiduQianfanEmbeddings)] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['baiduQianfanApi'] + } + this.inputs = [ + { + label: 'Model Name', + name: 'modelName', + type: 'asyncOptions', + loadMethod: 'listModels', + default: 'Embedding-V1' + }, + { + label: 'Custom Model Name', + name: 'customModelName', + type: 'string', + placeholder: 'Qwen3-Embedding-4B', + description: 'Custom model name to use. If provided, it will override the selected model.', + additionalParams: true, + optional: true + }, + { + label: 'Strip New Lines', + name: 'stripNewLines', + type: 'boolean', + optional: true, + additionalParams: true, + description: 'Remove new lines from input text before embedding to reduce token count' + }, + { + label: 'Batch Size', + name: 'batchSize', + type: 'number', + optional: true, + default: 1, + additionalParams: true, + description: 'Number of texts sent in each embedding request', + warning: + 'Qianfan has stricter limits on individual text length. If you encounter a length error, reduce chunk size to 500 and set Batch Size to 1.' + }, + { + label: 'Timeout', + name: 'timeout', + type: 'number', + optional: true, + additionalParams: true, + description: 'Request timeout in milliseconds' + } + ] + } + + //@ts-ignore + loadMethods = { + async listModels(): Promise { + return await getModels(MODEL_TYPE.EMBEDDING, 'baiduQianfanEmbeddings') + } + } + + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { + const modelName = nodeData.inputs?.modelName as BaiduQianfanEmbeddingsParams['modelName'] + const customModelName = nodeData.inputs?.customModelName as string + const stripNewLines = nodeData.inputs?.stripNewLines as boolean + const batchSize = nodeData.inputs?.batchSize as string + const timeout = nodeData.inputs?.timeout as string + + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const qianfanAccessKey = getCredentialParam('qianfanAccessKey', credentialData, nodeData) + const qianfanSecretKey = getCredentialParam('qianfanSecretKey', credentialData, nodeData) + + const obj: Partial & { + qianfanAccessKey?: string + qianfanSecretKey?: string + } = { + modelName: (customModelName || modelName) as BaiduQianfanEmbeddingsParams['modelName'], + qianfanAccessKey, + qianfanSecretKey + } + + if (typeof stripNewLines === 'boolean') obj.stripNewLines = stripNewLines + if (batchSize !== undefined && batchSize !== null && batchSize !== '') obj.batchSize = parseInt(batchSize, 10) + if (timeout !== undefined && timeout !== null && timeout !== '') obj.timeout = parseInt(timeout, 10) + + const model = new BaiduQianfanEmbeddings(obj) + return model + } +} + +module.exports = { nodeClass: BaiduQianfanEmbedding_Embeddings } diff --git a/packages/components/nodes/embeddings/BaiduQianfanEmbedding/baiduwenxin.svg b/packages/components/nodes/embeddings/BaiduQianfanEmbedding/baiduwenxin.svg new file mode 100644 index 00000000000..3b087025df0 --- /dev/null +++ b/packages/components/nodes/embeddings/BaiduQianfanEmbedding/baiduwenxin.svg @@ -0,0 +1,7 @@ + + diff --git a/packages/components/nodes/tools/ChatflowTool/ChatflowTool.ts b/packages/components/nodes/tools/ChatflowTool/ChatflowTool.ts index fa9d463aec1..5e8e4cb6efe 100644 --- a/packages/components/nodes/tools/ChatflowTool/ChatflowTool.ts +++ b/packages/components/nodes/tools/ChatflowTool/ChatflowTool.ts @@ -141,8 +141,8 @@ class ChatflowTool_Tools implements INode { type = 'AgentflowV2' } else if (type === 'MULTIAGENT') { type = 'AgentflowV1' - } else if (type === 'ASSISTANT') { - type = 'Custom Assistant' + } else if (type === 'ASSISTANT' || type === 'AGENT') { + type = 'Agent' } else { type = 'Chatflow' } diff --git a/packages/components/package.json b/packages/components/package.json index 2235af86724..5f24485ef29 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "flowise-components", - "version": "3.1.1", + "version": "3.1.2", "description": "Flowiseai Components", "main": "dist/src/index", "types": "dist/src/index.d.ts", @@ -18,7 +18,7 @@ "build": "tsc && gulp", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "clean": "rimraf dist", - "nuke": "rimraf dist node_modules .turbo", + "nuke": "pnpm clean && rimraf node_modules .turbo", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" @@ -136,6 +136,7 @@ "form-data": "^4.0.4", "google-auth-library": "^9.4.0", "graphql": "^16.6.0", + "groq-sdk": "1.1.2", "html-to-text": "^9.0.5", "ioredis": "^5.3.2", "ipaddr.js": "^2.2.0", diff --git a/packages/components/src/utils.ts b/packages/components/src/utils.ts index 36bdeb2a50f..f8b25da5119 100644 --- a/packages/components/src/utils.ts +++ b/packages/components/src/utils.ts @@ -1608,18 +1608,14 @@ export const executeJavaScriptCode = async ( } = {} ): Promise => { const { timeout = 300000, useSandbox = true, streamOutput, libraries = [], nodeVMOptions = {} } = options - if (useSandbox && !process.env.E2B_APIKEY) { - throw new Error( - 'Sandboxed code execution requires E2B_APIKEY to be configured. ' + - 'Set E2B_APIKEY in your environment or contact your administrator.' - ) - } + const shouldUseE2BSandbox = useSandbox && process.env.E2B_APIKEY + let timeoutMs = timeout if (process.env.SANDBOX_TIMEOUT) { timeoutMs = parseInt(process.env.SANDBOX_TIMEOUT, 10) } - if (useSandbox) { + if (shouldUseE2BSandbox) { try { const variableDeclarations = [] diff --git a/packages/server/.env.example b/packages/server/.env.example index 5b932c3fa8a..6afd6260f58 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -75,6 +75,7 @@ PORT=3000 # NUMBER_OF_PROXIES= 1 # CORS_ALLOW_CREDENTIALS=false # CORS_ORIGINS=* +# MCP_CORS_ORIGINS=* # IFRAME_ORIGINS=* # FLOWISE_FILE_SIZE_LIMIT=50mb # SHOW_COMMUNITY_NODES=true diff --git a/packages/server/package.json b/packages/server/package.json index 44bd72e65ad..45b4f9cf72b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "flowise", - "version": "3.1.1", + "version": "3.1.2", "description": "Flowiseai Server", "main": "dist/index", "types": "dist/index.d.ts", @@ -23,7 +23,7 @@ "build": "tsc && rimraf dist/enterprise/emails && gulp", "start": "run-script-os", "clean": "rimraf dist", - "nuke": "rimraf dist node_modules .turbo", + "nuke": "pnpm clean && rimraf node_modules .turbo", "start:windows": "cd bin && run start", "start:default": "cd bin && ./run start", "start-worker:windows": "cd bin && run worker", @@ -65,6 +65,7 @@ "@aws-sdk/client-secrets-manager": "^3.699.0", "@bull-board/api": "^6.11.0", "@bull-board/express": "^6.11.0", + "@modelcontextprotocol/sdk": "^1.10.1", "@google-cloud/logging-winston": "^6.0.0", "@keyv/redis": "^4.2.0", "@oclif/core": "4.0.7", @@ -150,7 +151,8 @@ "uuid": "^10.0.0", "winston": "^3.9.0", "winston-azure-blob": "^1.5.0", - "winston-daily-rotate-file": "^5.0.0" + "winston-daily-rotate-file": "^5.0.0", + "zod": "^3.25.76 || ^4" }, "devDependencies": { "@types/content-disposition": "0.5.8", diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts index 60175e23528..6b38ba61906 100644 --- a/packages/server/src/Interface.ts +++ b/packages/server/src/Interface.ts @@ -16,7 +16,7 @@ import { UsageCacheManager } from './UsageCacheManager' export type MessageType = 'apiMessage' | 'userMessage' -export type ChatflowType = 'CHATFLOW' | 'MULTIAGENT' | 'ASSISTANT' | 'AGENTFLOW' +export type ChatflowType = 'CHATFLOW' | 'MULTIAGENT' | 'ASSISTANT' | 'AGENTFLOW' | 'AGENT' export type AssistantType = 'CUSTOM' | 'OPENAI' | 'AZURE' @@ -30,7 +30,8 @@ export enum MODE { export enum ChatType { INTERNAL = 'INTERNAL', EXTERNAL = 'EXTERNAL', - EVALUATION = 'EVALUATION' + EVALUATION = 'EVALUATION', + MCP = 'MCP' } export enum ChatMessageRatingType { @@ -70,6 +71,7 @@ export interface IChatFlow { apiConfig?: string category?: string type?: ChatflowType + mcpServerConfig?: string workspaceId: string } @@ -403,6 +405,7 @@ export interface IExecuteFlowParams extends IPredictionQueueAppServer { parentExecutionId?: string iterationContext?: ICommonObject isTool?: boolean + chatType?: ChatType } export interface INodeOverrides { @@ -421,6 +424,13 @@ export interface IVariableOverride { enabled: boolean } +export interface IMcpServerConfig { + enabled: boolean + token: string + description?: string + toolName?: string +} + // DocumentStore related export * from './Interface.DocumentStore' diff --git a/packages/server/src/commands/base.ts b/packages/server/src/commands/base.ts index 6de87fa4d03..a559026a076 100644 --- a/packages/server/src/commands/base.ts +++ b/packages/server/src/commands/base.ts @@ -16,6 +16,7 @@ export abstract class BaseCommand extends Command { FLOWISE_FILE_SIZE_LIMIT: Flags.string(), PORT: Flags.string(), CORS_ORIGINS: Flags.string(), + MCP_CORS_ORIGINS: Flags.string(), IFRAME_ORIGINS: Flags.string(), DEBUG: Flags.string(), NUMBER_OF_PROXIES: Flags.string(), diff --git a/packages/server/src/controllers/mcp-endpoint/index.test.ts b/packages/server/src/controllers/mcp-endpoint/index.test.ts new file mode 100644 index 00000000000..c9df826d893 --- /dev/null +++ b/packages/server/src/controllers/mcp-endpoint/index.test.ts @@ -0,0 +1,177 @@ +/** + * Unit tests for MCP endpoint controller (packages/server/src/controllers/mcp-endpoint/index.ts) + * + * Tests the Express request handlers: token extraction, auth enforcement, + * rate limiter middleware delegation, and request routing to the service layer. + */ +import { Request, Response, NextFunction } from 'express' + +// --- Mock setup --- +const mockHandleMcpRequest = jest.fn() +const mockHandleMcpDeleteRequest = jest.fn() + +jest.mock('../../services/mcp-endpoint', () => ({ + __esModule: true, + default: { + handleMcpRequest: (...args: any[]) => mockHandleMcpRequest(...args), + handleMcpDeleteRequest: (...args: any[]) => mockHandleMcpDeleteRequest(...args) + } +})) + +const mockGetRateLimiter = jest.fn().mockReturnValue((_req: any, _res: any, next: any) => next()) + +jest.mock('../../utils/rateLimit', () => ({ + RateLimiterManager: { + getInstance: () => ({ + getRateLimiter: () => mockGetRateLimiter() + }) + } +})) + +jest.mock('../../utils/logger', () => ({ + __esModule: true, + default: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + } +})) + +// Import after mocking +import mcpEndpointController from '.' + +// Helper: create mock Express objects +function mockReq(overrides: Record = {}): Request { + return { + params: { chatflowId: 'flow-123' }, + headers: {}, + query: {}, + get: jest.fn(), + ...overrides + } as unknown as Request +} + +function mockRes(): Response { + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + locals: {} + } + return res as Response +} + +function mockNext(): NextFunction { + return jest.fn() +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('MCP Endpoint Controller', () => { + describe('authenticateToken', () => { + it('returns 401 when Authorization header is missing', () => { + const req = mockReq({ headers: {} }) + const res = mockRes() + const next = mockNext() + + mcpEndpointController.authenticateToken(req, res, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32001 }) + }) + ) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 401 when Authorization header is not Bearer', () => { + const req = mockReq({ headers: { authorization: 'Basic dXNlcjpwYXNz' } }) + const res = mockRes() + const next = mockNext() + + mcpEndpointController.authenticateToken(req, res, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('returns 401 when Bearer token is empty', () => { + const req = mockReq({ headers: { authorization: 'Bearer ' } }) + const res = mockRes() + const next = mockNext() + + mcpEndpointController.authenticateToken(req, res, next) + + expect(res.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('sets res.locals.token and calls next on valid Bearer token', () => { + const req = mockReq({ headers: { authorization: 'Bearer my-secret-token' } }) + const res = mockRes() + const next = mockNext() + + mcpEndpointController.authenticateToken(req, res, next) + + expect(res.locals.token).toBe('my-secret-token') + expect(next).toHaveBeenCalled() + expect(res.status).not.toHaveBeenCalled() + }) + }) + + describe('handlePost', () => { + it('calls service with chatflowId and token from res.locals.token', async () => { + const req = mockReq({ params: { chatflowId: 'flow-123' } }) + const res = mockRes() + res.locals.token = 'my-secret-token' + const next = mockNext() + mockHandleMcpRequest.mockResolvedValue(undefined) + + await mcpEndpointController.handlePost(req, res, next) + + expect(mockHandleMcpRequest).toHaveBeenCalledWith('flow-123', 'my-secret-token', req, res) + }) + + it('calls next(error) on unexpected errors', async () => { + const req = mockReq({ params: { chatflowId: 'flow-123' } }) + const res = mockRes() + res.locals.token = 'token' + const next = mockNext() + const error = new Error('Unexpected') + mockHandleMcpRequest.mockRejectedValue(error) + + await mcpEndpointController.handlePost(req, res, next) + + expect(next).toHaveBeenCalledWith(error) + }) + }) + + describe('handleDelete', () => { + it('delegates to handleMcpDeleteRequest with chatflowId', async () => { + const req = mockReq({ params: { chatflowId: 'flow-789' } }) + const res = mockRes() + const next = mockNext() + mockHandleMcpDeleteRequest.mockResolvedValue(undefined) + + await mcpEndpointController.handleDelete(req, res, next) + + expect(mockHandleMcpDeleteRequest).toHaveBeenCalledWith('flow-789', req, res) + }) + }) + + describe('getRateLimiterMiddleware', () => { + it('delegates to RateLimiterManager', async () => { + const req = mockReq() + const res = mockRes() + const next = mockNext() + + await mcpEndpointController.getRateLimiterMiddleware(req, res, next) + + expect(mockGetRateLimiter).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/server/src/controllers/mcp-endpoint/index.ts b/packages/server/src/controllers/mcp-endpoint/index.ts new file mode 100644 index 00000000000..7c136cf4cf6 --- /dev/null +++ b/packages/server/src/controllers/mcp-endpoint/index.ts @@ -0,0 +1,78 @@ +import { NextFunction, Request, Response } from 'express' +import mcpEndpointService from '../../services/mcp-endpoint' +import { RateLimiterManager } from '../../utils/rateLimit' +import logger from '../../utils/logger' + +/** + * Extract token from the Authorization: Bearer header. + * Returns null if not present or malformed. + */ +function extractToken(req: Request): string | null { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) return null + const token = authHeader.slice(7).trim() + return token.length > 0 ? token : null +} + +/** + * Authentication middleware — validates Bearer token and attaches it to res.locals. + */ +const authenticateToken = (req: Request, res: Response, next: NextFunction) => { + const token = extractToken(req) + if (!token) { + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized: missing or invalid Authorization header. Use Bearer .' }, + id: null + }) + return + } + res.locals.token = token + next() +} + +/** + * Rate limiter middleware for MCP endpoint — reuses per-chatflow rate limiters. + */ +const getRateLimiterMiddleware = async (req: Request, res: Response, next: NextFunction) => { + try { + return RateLimiterManager.getInstance().getRateLimiter()(req, res, next) + } catch (error) { + next(error) + } +} + +/** + * Handle POST /api/v1/mcp/:chatflowId — MCP JSON-RPC messages + * Auth: token must be in Authorization: Bearer header + */ +const handlePost = async (req: Request, res: Response, next: NextFunction) => { + try { + const { chatflowId } = req.params + const token = res.locals.token as string + + logger.debug(`[MCP] POST request for chatflow: ${chatflowId}`) + await mcpEndpointService.handleMcpRequest(chatflowId, token, req, res) + } catch (error) { + next(error) + } +} + +/** + * Handle DELETE /api/v1/mcp/:chatflowId — Session termination + */ +const handleDelete = async (req: Request, res: Response, next: NextFunction) => { + try { + const { chatflowId } = req.params + await mcpEndpointService.handleMcpDeleteRequest(chatflowId, req, res) + } catch (error) { + next(error) + } +} + +export default { + authenticateToken, + handlePost, + handleDelete, + getRateLimiterMiddleware +} diff --git a/packages/server/src/controllers/mcp-server/index.ts b/packages/server/src/controllers/mcp-server/index.ts new file mode 100644 index 00000000000..c86099a482f --- /dev/null +++ b/packages/server/src/controllers/mcp-server/index.ts @@ -0,0 +1,104 @@ +import { NextFunction, Request, Response } from 'express' +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import mcpServerService from '../../services/mcp-server' + +const getMcpServerConfig = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: mcpServerController.getMcpServerConfig - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Error: mcpServerController.getMcpServerConfig - workspace not found!') + } + const apiResponse = await mcpServerService.getMcpServerConfig(req.params.id, workspaceId) + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + +const createMcpServerConfig = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: mcpServerController.createMcpServerConfig - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Error: mcpServerController.createMcpServerConfig - workspace not found!') + } + const apiResponse = await mcpServerService.createMcpServerConfig(req.params.id, workspaceId, req.body || {}) + return res.status(StatusCodes.CREATED).json(apiResponse) + } catch (error) { + next(error) + } +} + +const updateMcpServerConfig = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: mcpServerController.updateMcpServerConfig - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Error: mcpServerController.updateMcpServerConfig - workspace not found!') + } + const apiResponse = await mcpServerService.updateMcpServerConfig(req.params.id, workspaceId, req.body || {}) + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + +const deleteMcpServerConfig = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError( + StatusCodes.PRECONDITION_FAILED, + 'Error: mcpServerController.deleteMcpServerConfig - id not provided!' + ) + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Error: mcpServerController.deleteMcpServerConfig - workspace not found!') + } + await mcpServerService.deleteMcpServerConfig(req.params.id, workspaceId) + return res.json({ message: 'MCP server config disabled' }) + } catch (error) { + next(error) + } +} + +const refreshMcpToken = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.params.id) { + throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'Error: mcpServerController.refreshMcpToken - id not provided!') + } + const workspaceId = req.user?.activeWorkspaceId + if (!workspaceId) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Error: mcpServerController.refreshMcpToken - workspace not found!') + } + const apiResponse = await mcpServerService.refreshMcpToken(req.params.id, workspaceId) + return res.json(apiResponse) + } catch (error) { + next(error) + } +} + +export default { + getMcpServerConfig, + createMcpServerConfig, + updateMcpServerConfig, + deleteMcpServerConfig, + refreshMcpToken +} diff --git a/packages/server/src/database/entities/ChatFlow.ts b/packages/server/src/database/entities/ChatFlow.ts index d3561aa0017..37b68c7ecbe 100644 --- a/packages/server/src/database/entities/ChatFlow.ts +++ b/packages/server/src/database/entities/ChatFlow.ts @@ -6,7 +6,8 @@ export enum EnumChatflowType { CHATFLOW = 'CHATFLOW', AGENTFLOW = 'AGENTFLOW', MULTIAGENT = 'MULTIAGENT', - ASSISTANT = 'ASSISTANT' + ASSISTANT = 'ASSISTANT', + AGENT = 'AGENT' } @Entity() @@ -61,6 +62,9 @@ export class ChatFlow implements IChatFlow { @UpdateDateColumn() updatedDate: Date + @Column({ nullable: true, type: 'text' }) + mcpServerConfig?: string + @Column({ nullable: false, type: 'text' }) workspaceId: string } diff --git a/packages/server/src/database/migrations/mariadb/1767000000000-AddMcpServerConfigToChatFlow.ts b/packages/server/src/database/migrations/mariadb/1767000000000-AddMcpServerConfigToChatFlow.ts new file mode 100644 index 00000000000..f65cbae17ad --- /dev/null +++ b/packages/server/src/database/migrations/mariadb/1767000000000-AddMcpServerConfigToChatFlow.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddMcpServerConfigToChatFlow1767000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`chat_flow\` ADD COLUMN \`mcpServerConfig\` LONGTEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`chat_flow\` DROP COLUMN \`mcpServerConfig\`;`) + } +} diff --git a/packages/server/src/database/migrations/mariadb/1775497538678-AddAgentsPermission.ts b/packages/server/src/database/migrations/mariadb/1775497538678-AddAgentsPermission.ts new file mode 100644 index 00000000000..8c69ab3fa95 --- /dev/null +++ b/packages/server/src/database/migrations/mariadb/1775497538678-AddAgentsPermission.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddAgentsPermission1775497538678 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add agents:* permissions to roles that have corresponding assistants:* permissions + // Uses SQL REPLACE for O(1) bulk operations — safe for millions of rows + + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:view"', '"assistants:view","agents:view"') WHERE \`permissions\` LIKE '%assistants:view%' AND \`permissions\` NOT LIKE '%agents:view%';` + ) + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE \`permissions\` LIKE '%assistants:create%' AND \`permissions\` NOT LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE \`permissions\` LIKE '%assistants:update%' AND \`permissions\` NOT LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE \`permissions\` LIKE '%assistants:delete%' AND \`permissions\` NOT LIKE '%agents:delete%';` + ) + + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:view"', '"assistants:view","agents:view"') WHERE \`permissions\` LIKE '%assistants:view%' AND \`permissions\` NOT LIKE '%agents:view%';` + ) + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE \`permissions\` LIKE '%assistants:create%' AND \`permissions\` NOT LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE \`permissions\` LIKE '%assistants:update%' AND \`permissions\` NOT LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE \`permissions\` LIKE '%assistants:delete%' AND \`permissions\` NOT LIKE '%agents:delete%';` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + const tables = ['`role`', '`apikey`'] + for (const table of tables) { + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:delete","agents:delete"', '"assistants:delete"') WHERE \`permissions\` LIKE '%agents:delete%';` + ) + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:update","agents:update","agents:config","agents:domains"', '"assistants:update"') WHERE \`permissions\` LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"', '"assistants:create"') WHERE \`permissions\` LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:view","agents:view"', '"assistants:view"') WHERE \`permissions\` LIKE '%agents:view%';` + ) + } + } +} diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts index f9d3d5fdcd8..42856276336 100644 --- a/packages/server/src/database/migrations/mariadb/index.ts +++ b/packages/server/src/database/migrations/mariadb/index.ts @@ -43,6 +43,9 @@ import { AddChatFlowNameIndex1759424809984 } from './1759424809984-AddChatFlowNa import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' +import { AddAgentsPermission1775497538678 } from './1775497538678-AddAgentsPermission' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mariadb/1720230151482-AddAuthTables' import { AddWorkspace1725437498242 } from '../../../enterprise/database/migrations/mariadb/1725437498242-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/mariadb/1726654922034-AddWorkspaceShared' @@ -111,5 +114,7 @@ export const mariadbMigrations = [ AddChatFlowNameIndex1759424809984, FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddMcpServerConfigToChatFlow1767000000000, + AddAgentsPermission1775497538678 ] diff --git a/packages/server/src/database/migrations/mysql/1767000000000-AddMcpServerConfigToChatFlow.ts b/packages/server/src/database/migrations/mysql/1767000000000-AddMcpServerConfigToChatFlow.ts new file mode 100644 index 00000000000..f65cbae17ad --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1767000000000-AddMcpServerConfigToChatFlow.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddMcpServerConfigToChatFlow1767000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`chat_flow\` ADD COLUMN \`mcpServerConfig\` LONGTEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`chat_flow\` DROP COLUMN \`mcpServerConfig\`;`) + } +} diff --git a/packages/server/src/database/migrations/mysql/1775497538678-AddAgentsPermission.ts b/packages/server/src/database/migrations/mysql/1775497538678-AddAgentsPermission.ts new file mode 100644 index 00000000000..8c69ab3fa95 --- /dev/null +++ b/packages/server/src/database/migrations/mysql/1775497538678-AddAgentsPermission.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddAgentsPermission1775497538678 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add agents:* permissions to roles that have corresponding assistants:* permissions + // Uses SQL REPLACE for O(1) bulk operations — safe for millions of rows + + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:view"', '"assistants:view","agents:view"') WHERE \`permissions\` LIKE '%assistants:view%' AND \`permissions\` NOT LIKE '%agents:view%';` + ) + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE \`permissions\` LIKE '%assistants:create%' AND \`permissions\` NOT LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE \`permissions\` LIKE '%assistants:update%' AND \`permissions\` NOT LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE \`role\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE \`permissions\` LIKE '%assistants:delete%' AND \`permissions\` NOT LIKE '%agents:delete%';` + ) + + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:view"', '"assistants:view","agents:view"') WHERE \`permissions\` LIKE '%assistants:view%' AND \`permissions\` NOT LIKE '%agents:view%';` + ) + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE \`permissions\` LIKE '%assistants:create%' AND \`permissions\` NOT LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE \`permissions\` LIKE '%assistants:update%' AND \`permissions\` NOT LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE \`apikey\` SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE \`permissions\` LIKE '%assistants:delete%' AND \`permissions\` NOT LIKE '%agents:delete%';` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + const tables = ['`role`', '`apikey`'] + for (const table of tables) { + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:delete","agents:delete"', '"assistants:delete"') WHERE \`permissions\` LIKE '%agents:delete%';` + ) + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:update","agents:update","agents:config","agents:domains"', '"assistants:update"') WHERE \`permissions\` LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"', '"assistants:create"') WHERE \`permissions\` LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE ${table} SET \`permissions\` = REPLACE(\`permissions\`, '"assistants:view","agents:view"', '"assistants:view"') WHERE \`permissions\` LIKE '%agents:view%';` + ) + } + } +} diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts index a22168aefcf..b97c5379ff3 100644 --- a/packages/server/src/database/migrations/mysql/index.ts +++ b/packages/server/src/database/migrations/mysql/index.ts @@ -44,6 +44,9 @@ import { AddChatFlowNameIndex1759424828558 } from './1759424828558-AddChatFlowNa import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' +import { AddAgentsPermission1775497538678 } from './1775497538678-AddAgentsPermission' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/mysql/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/mysql/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/mysql/1726654922034-AddWorkspaceShared' @@ -113,5 +116,7 @@ export const mysqlMigrations = [ AddChatFlowNameIndex1759424828558, FixDocumentStoreFileChunkLongText1765000000000, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddMcpServerConfigToChatFlow1767000000000, + AddAgentsPermission1775497538678 ] diff --git a/packages/server/src/database/migrations/postgres/1767000000000-AddMcpServerConfigToChatFlow.ts b/packages/server/src/database/migrations/postgres/1767000000000-AddMcpServerConfigToChatFlow.ts new file mode 100644 index 00000000000..5c17c05ccbb --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1767000000000-AddMcpServerConfigToChatFlow.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddMcpServerConfigToChatFlow1767000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "mcpServerConfig" TEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "mcpServerConfig";`) + } +} diff --git a/packages/server/src/database/migrations/postgres/1775497538678-AddAgentsPermission.ts b/packages/server/src/database/migrations/postgres/1775497538678-AddAgentsPermission.ts new file mode 100644 index 00000000000..25143a163ce --- /dev/null +++ b/packages/server/src/database/migrations/postgres/1775497538678-AddAgentsPermission.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddAgentsPermission1775497538678 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add agents:* permissions to roles that have corresponding assistants:* permissions + // Uses SQL replace() for O(1) bulk operations — safe for millions of rows + + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:view"', '"assistants:view","agents:view"') WHERE "permissions" LIKE '%assistants:view%' AND "permissions" NOT LIKE '%agents:view%';` + ) + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE "permissions" LIKE '%assistants:create%' AND "permissions" NOT LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE "permissions" LIKE '%assistants:update%' AND "permissions" NOT LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE "permissions" LIKE '%assistants:delete%' AND "permissions" NOT LIKE '%agents:delete%';` + ) + + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:view"', '"assistants:view","agents:view"') WHERE "permissions" LIKE '%assistants:view%' AND "permissions" NOT LIKE '%agents:view%';` + ) + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE "permissions" LIKE '%assistants:create%' AND "permissions" NOT LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE "permissions" LIKE '%assistants:update%' AND "permissions" NOT LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE "permissions" LIKE '%assistants:delete%' AND "permissions" NOT LIKE '%agents:delete%';` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + const tables = ['"role"', '"apikey"'] + for (const table of tables) { + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:delete","agents:delete"', '"assistants:delete"') WHERE "permissions" LIKE '%agents:delete%';` + ) + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:update","agents:update","agents:config","agents:domains"', '"assistants:update"') WHERE "permissions" LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"', '"assistants:create"') WHERE "permissions" LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:view","agents:view"', '"assistants:view"') WHERE "permissions" LIKE '%agents:view%';` + ) + } + } +} diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts index 9303033e02b..7823127117b 100644 --- a/packages/server/src/database/migrations/postgres/index.ts +++ b/packages/server/src/database/migrations/postgres/index.ts @@ -42,6 +42,9 @@ import { AddTextToSpeechToChatFlow1759419194331 } from './1759419194331-AddTextT import { AddChatFlowNameIndex1759424903973 } from './1759424903973-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' +import { AddAgentsPermission1775497538678 } from './1775497538678-AddAgentsPermission' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/postgres/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/postgres/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/postgres/1726654922034-AddWorkspaceShared' @@ -109,5 +112,7 @@ export const postgresMigrations = [ AddTextToSpeechToChatFlow1759419194331, AddChatFlowNameIndex1759424903973, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddMcpServerConfigToChatFlow1767000000000, + AddAgentsPermission1775497538678 ] diff --git a/packages/server/src/database/migrations/sqlite/1767000000000-AddMcpServerConfigToChatFlow.ts b/packages/server/src/database/migrations/sqlite/1767000000000-AddMcpServerConfigToChatFlow.ts new file mode 100644 index 00000000000..e31e1c43973 --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1767000000000-AddMcpServerConfigToChatFlow.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddMcpServerConfigToChatFlow1767000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "mcpServerConfig" TEXT;`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "mcpServerConfig";`) + } +} diff --git a/packages/server/src/database/migrations/sqlite/1775497538678-AddAgentsPermission.ts b/packages/server/src/database/migrations/sqlite/1775497538678-AddAgentsPermission.ts new file mode 100644 index 00000000000..7fd72d08b99 --- /dev/null +++ b/packages/server/src/database/migrations/sqlite/1775497538678-AddAgentsPermission.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddAgentsPermission1775497538678 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add agents:* permissions to roles that have corresponding assistants:* permissions + // Uses SQL REPLACE for O(1) bulk operations — safe for millions of rows + + // assistants:view → agents:view + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:view"', '"assistants:view","agents:view"') WHERE "permissions" LIKE '%assistants:view%' AND "permissions" NOT LIKE '%agents:view%';` + ) + // assistants:create → agents:create,duplicate,export,import + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE "permissions" LIKE '%assistants:create%' AND "permissions" NOT LIKE '%agents:create%';` + ) + // assistants:update → agents:update,config,domains + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE "permissions" LIKE '%assistants:update%' AND "permissions" NOT LIKE '%agents:update%';` + ) + // assistants:delete → agents:delete + await queryRunner.query( + `UPDATE "role" SET "permissions" = REPLACE("permissions", '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE "permissions" LIKE '%assistants:delete%' AND "permissions" NOT LIKE '%agents:delete%';` + ) + + // Same for apikey table + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:view"', '"assistants:view","agents:view"') WHERE "permissions" LIKE '%assistants:view%' AND "permissions" NOT LIKE '%agents:view%';` + ) + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:create"', '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"') WHERE "permissions" LIKE '%assistants:create%' AND "permissions" NOT LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:update"', '"assistants:update","agents:update","agents:config","agents:domains"') WHERE "permissions" LIKE '%assistants:update%' AND "permissions" NOT LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE "apikey" SET "permissions" = REPLACE("permissions", '"assistants:delete"', '"assistants:delete","agents:delete"') WHERE "permissions" LIKE '%assistants:delete%' AND "permissions" NOT LIKE '%agents:delete%';` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse: remove agents:* permissions by replacing the expanded strings back to originals + // Order matters — reverse the up() insertion order + + const tables = ['"role"', '"apikey"'] + for (const table of tables) { + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:delete","agents:delete"', '"assistants:delete"') WHERE "permissions" LIKE '%agents:delete%';` + ) + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:update","agents:update","agents:config","agents:domains"', '"assistants:update"') WHERE "permissions" LIKE '%agents:update%';` + ) + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:create","agents:create","agents:duplicate","agents:export","agents:import"', '"assistants:create"') WHERE "permissions" LIKE '%agents:create%';` + ) + await queryRunner.query( + `UPDATE ${table} SET "permissions" = REPLACE("permissions", '"assistants:view","agents:view"', '"assistants:view"') WHERE "permissions" LIKE '%agents:view%';` + ) + } + } +} diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts index 90b42a2475f..481ddd4fc8d 100644 --- a/packages/server/src/database/migrations/sqlite/index.ts +++ b/packages/server/src/database/migrations/sqlite/index.ts @@ -40,6 +40,9 @@ import { AddTextToSpeechToChatFlow1759419136055 } from './1759419136055-AddTextT import { AddChatFlowNameIndex1759424923093 } from './1759424923093-AddChatFlowNameIndex' import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission' import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage' +import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow' +import { AddAgentsPermission1775497538678 } from './1775497538678-AddAgentsPermission' + import { AddAuthTables1720230151482 } from '../../../enterprise/database/migrations/sqlite/1720230151482-AddAuthTables' import { AddWorkspace1720230151484 } from '../../../enterprise/database/migrations/sqlite/1720230151484-AddWorkspace' import { AddWorkspaceShared1726654922034 } from '../../../enterprise/database/migrations/sqlite/1726654922034-AddWorkspaceShared' @@ -105,5 +108,7 @@ export const sqliteMigrations = [ AddTextToSpeechToChatFlow1759419136055, AddChatFlowNameIndex1759424923093, AddApiKeyPermission1765360298674, - AddReasonContentToChatMessage1764759496768 + AddReasonContentToChatMessage1764759496768, + AddMcpServerConfigToChatFlow1767000000000, + AddAgentsPermission1775497538678 ] diff --git a/packages/server/src/enterprise/rbac/Permissions.ts b/packages/server/src/enterprise/rbac/Permissions.ts index 1fb9e6fccbf..2fa56e57cf4 100644 --- a/packages/server/src/enterprise/rbac/Permissions.ts +++ b/packages/server/src/enterprise/rbac/Permissions.ts @@ -5,17 +5,17 @@ export class Permissions { // auditCategory.addPermission(new Permission('auditLogs:view', 'View Audit Logs')) // this.categories.push(auditCategory) - const chatflowsCategory = new PermissionCategory('chatflows') - chatflowsCategory.addPermission(new Permission('chatflows:view', 'View', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:create', 'Create', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:update', 'Update', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:duplicate', 'Duplicate', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:delete', 'Delete', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:export', 'Export', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:import', 'Import', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:config', 'Edit Configuration', true, true, true)) - chatflowsCategory.addPermission(new Permission('chatflows:domains', 'Allowed Domains', true, true, true)) - this.categories.push(chatflowsCategory) + const agentsCategory = new PermissionCategory('agents') + agentsCategory.addPermission(new Permission('agents:view', 'View', true, true, true)) + agentsCategory.addPermission(new Permission('agents:create', 'Create', true, true, true)) + agentsCategory.addPermission(new Permission('agents:update', 'Update', true, true, true)) + agentsCategory.addPermission(new Permission('agents:duplicate', 'Duplicate', true, true, true)) + agentsCategory.addPermission(new Permission('agents:delete', 'Delete', true, true, true)) + agentsCategory.addPermission(new Permission('agents:export', 'Export', true, true, true)) + agentsCategory.addPermission(new Permission('agents:import', 'Import', true, true, true)) + agentsCategory.addPermission(new Permission('agents:config', 'Edit Configuration', true, true, true)) + agentsCategory.addPermission(new Permission('agents:domains', 'Allowed Domains', true, true, true)) + this.categories.push(agentsCategory) const agentflowsCategory = new PermissionCategory('agentflows') agentflowsCategory.addPermission(new Permission('agentflows:view', 'View', true, true, true)) @@ -29,6 +29,23 @@ export class Permissions { agentflowsCategory.addPermission(new Permission('agentflows:domains', 'Allowed Domains', true, true, true)) this.categories.push(agentflowsCategory) + const executionsCategory = new PermissionCategory('executions') + executionsCategory.addPermission(new Permission('executions:view', 'View', true, true, true)) + executionsCategory.addPermission(new Permission('executions:delete', 'Delete', true, true, true)) + this.categories.push(executionsCategory) + + const chatflowsCategory = new PermissionCategory('chatflows') + chatflowsCategory.addPermission(new Permission('chatflows:view', 'View', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:create', 'Create', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:update', 'Update', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:duplicate', 'Duplicate', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:delete', 'Delete', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:export', 'Export', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:import', 'Import', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:config', 'Edit Configuration', true, true, true)) + chatflowsCategory.addPermission(new Permission('chatflows:domains', 'Allowed Domains', true, true, true)) + this.categories.push(chatflowsCategory) + const toolsCategory = new PermissionCategory('tools') toolsCategory.addPermission(new Permission('tools:view', 'View', true, true, true)) toolsCategory.addPermission(new Permission('tools:create', 'Create', true, true, true)) @@ -86,11 +103,6 @@ export class Permissions { datasetsCategory.addPermission(new Permission('datasets:delete', 'Delete', false, true, true)) this.categories.push(datasetsCategory) - const executionsCategory = new PermissionCategory('executions') - executionsCategory.addPermission(new Permission('executions:view', 'View', true, true, true)) - executionsCategory.addPermission(new Permission('executions:delete', 'Delete', true, true, true)) - this.categories.push(executionsCategory) - const evaluatorsCategory = new PermissionCategory('evaluators') evaluatorsCategory.addPermission(new Permission('evaluators:view', 'View', false, true, true)) evaluatorsCategory.addPermission(new Permission('evaluators:create', 'Create', false, true, true)) diff --git a/packages/server/src/enterprise/services/organization.service.ts b/packages/server/src/enterprise/services/organization.service.ts index 2b181856574..1c0de448ea2 100644 --- a/packages/server/src/enterprise/services/organization.service.ts +++ b/packages/server/src/enterprise/services/organization.service.ts @@ -4,6 +4,7 @@ import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { generateId } from '../../utils' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { Telemetry } from '../../utils/telemetry' +import { Credential } from '../../database/entities/Credential' import { Organization, OrganizationName } from '../database/entities/organization.entity' import { isInvalidName, isInvalidUUID } from '../utils/validation.util' import { UserErrorMessage, UserService } from './user.service' @@ -35,7 +36,42 @@ export class OrganizationService { public async readOrganizationById(id: string | undefined, queryRunner: QueryRunner) { this.validateOrganizationId(id) - return await queryRunner.manager.findOneBy(Organization, { id }) + const organization = await queryRunner.manager.findOneBy(Organization, { id }) + if (!organization) return null + + // TODO: Replace mocked defaultConfig with actual governance settings from database + const credential = await queryRunner.manager.findOneBy(Credential, { credentialName: 'openAIApi' }) + const credentialId = credential?.id || '' + const defaultConfig = { + chatModel: { + name: 'chatOpenAI', + label: 'OpenAI', + inputs: { + cache: '', + modelName: 'gpt-5.4', + temperature: 0.9, + streaming: true, + allowImageUploads: '', + reasoning: '', + reasoningEffort: '', + reasoningSummary: '', + maxTokens: '', + topP: '', + frequencyPenalty: '', + presencePenalty: '', + timeout: '', + strictToolCalling: '', + stopSequence: '', + basepath: '', + baseOptions: '', + FLOWISE_CREDENTIAL_ID: credentialId, + credential: credentialId + }, + credential: credentialId + } + } + + return { ...organization, defaultConfig: JSON.stringify(defaultConfig) } } public validateOrganizationName(name: string | undefined, isRegister: boolean = false) { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b0b031d633a..91c88fafcdb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -189,13 +189,20 @@ export class App { // Allow embedding from specified domains. this.app.use((req, res, next) => { const allowedOrigins = getAllowedIframeOrigins() - if (allowedOrigins == '*') { - next() + if (allowedOrigins === '*') { + // Explicitly allow all origins (only when user opts in) + res.setHeader('Content-Security-Policy', 'frame-ancestors *') } else { const csp = `frame-ancestors ${allowedOrigins}` res.setHeader('Content-Security-Policy', csp) - next() + // X-Frame-Options for legacy browser support + if (allowedOrigins === "'self'") { + res.setHeader('X-Frame-Options', 'SAMEORIGIN') + } else { + res.setHeader('X-Frame-Options', 'DENY') + } } + next() }) // Switch off the default 'X-Powered-By: Express' header diff --git a/packages/server/src/queue/RedisEventPublisher.ts b/packages/server/src/queue/RedisEventPublisher.ts index 52b150d87e3..2c839072ae3 100644 --- a/packages/server/src/queue/RedisEventPublisher.ts +++ b/packages/server/src/queue/RedisEventPublisher.ts @@ -198,6 +198,9 @@ export class RedisEventPublisher implements IServerSideEventStreamer { if (apiResponse.memoryType) { metadataJson['memoryType'] = apiResponse.memoryType } + if (apiResponse.action) { + metadataJson['action'] = typeof apiResponse.action === 'string' ? JSON.parse(apiResponse.action) : apiResponse.action + } if (Object.keys(metadataJson).length > 0) { this.streamCustomEvent(chatId, 'metadata', metadataJson) } diff --git a/packages/server/src/routes/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts index 5d2ec2609ec..d8dcef3c0a3 100644 --- a/packages/server/src/routes/chatflows/index.ts +++ b/packages/server/src/routes/chatflows/index.ts @@ -6,19 +6,21 @@ const router = express.Router() // CREATE router.post( '/', - checkAnyPermission('chatflows:create,chatflows:update,agentflows:create,agentflows:update'), + checkAnyPermission('chatflows:create,chatflows:update,agentflows:create,agentflows:update,agents:create,agents:update'), chatflowsController.saveChatflow ) // READ router.get( '/', - checkAnyPermission('chatflows:view,chatflows:update,agentflows:view,agentflows:update'), + checkAnyPermission('chatflows:view,chatflows:update,agentflows:view,agentflows:update,agents:view,agents:update'), chatflowsController.getAllChatflows ) router.get( ['/', '/:id'], - checkAnyPermission('chatflows:view,chatflows:update,chatflows:delete,agentflows:view,agentflows:update,agentflows:delete'), + checkAnyPermission( + 'chatflows:view,chatflows:update,chatflows:delete,agentflows:view,agentflows:update,agentflows:delete,agents:view,agents:update,agents:delete' + ), chatflowsController.getChatflowById ) router.get(['/apikey/', '/apikey/:apikey'], chatflowsController.getChatflowByApiKey) @@ -26,17 +28,17 @@ router.get(['/apikey/', '/apikey/:apikey'], chatflowsController.getChatflowByApi // UPDATE router.put( ['/', '/:id'], - checkAnyPermission('chatflows:create,chatflows:update,agentflows:create,agentflows:update'), + checkAnyPermission('chatflows:create,chatflows:update,agentflows:create,agentflows:update,agents:create,agents:update'), chatflowsController.updateChatflow ) // DELETE -router.delete(['/', '/:id'], checkAnyPermission('chatflows:delete,agentflows:delete'), chatflowsController.deleteChatflow) +router.delete(['/', '/:id'], checkAnyPermission('chatflows:delete,agentflows:delete,agents:delete'), chatflowsController.deleteChatflow) // CHECK FOR CHANGE router.get( '/has-changed/:id/:lastUpdatedDateTime', - checkAnyPermission('chatflows:update,agentflows:update'), + checkAnyPermission('chatflows:update,agentflows:update,agents:update'), chatflowsController.checkIfChatflowHasChanged ) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index bb7ce05d896..6407cdddc85 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -55,6 +55,8 @@ import executionsRouter from './executions' import validationRouter from './validation' import agentflowv2GeneratorRouter from './agentflowv2-generator' import textToSpeechRouter from './text-to-speech' +import mcpServerRouter from './mcp-server' +import mcpEndpointRouter from './mcp-endpoint' import authRouter from '../enterprise/routes/auth' import auditRouter from '../enterprise/routes/audit' @@ -124,6 +126,8 @@ router.use('/executions', executionsRouter) router.use('/validation', validationRouter) router.use('/agentflowv2-generator', agentflowv2GeneratorRouter) router.use('/text-to-speech', textToSpeechRouter) +router.use('/mcp-server', mcpServerRouter) +router.use('/mcp', mcpEndpointRouter) router.use('/auth', authRouter) router.use('/audit', IdentityManager.checkFeatureByPlan('feat:login-activity'), auditRouter) diff --git a/packages/server/src/routes/mcp-endpoint/index.test.ts b/packages/server/src/routes/mcp-endpoint/index.test.ts new file mode 100644 index 00000000000..8b3f89ea061 --- /dev/null +++ b/packages/server/src/routes/mcp-endpoint/index.test.ts @@ -0,0 +1,242 @@ +/** + * Unit tests for MCP endpoint router (packages/server/src/routes/mcp-endpoint/index.ts) + * + * Covers: + * - Body size limit: payloads > 1 MiB are rejected with 413; payloads ≤ 1 MiB pass through. + * - CORS behaviour under three MCP_CORS_ORIGINS configurations: + * • unset – non-browser (no Origin) allowed; browser Origin blocked (no ACAO header) + * • '*' – all origins allowed; cors echoes request origin as ACAO (origin: true behaviour) + * • list – only listed origins get ACAO header; unlisted origins are silently blocked + * + * Note on cors blocking: the `cors` npm package never returns 403 for blocked origins. + * It omits the Access-Control-Allow-Origin (ACAO) header and calls next(), letting the + * browser enforce the policy client-side. Tests therefore assert on the presence/absence + * of the ACAO header rather than on a 403 status code. + */ +import express, { Request, Response } from 'express' +import request from 'supertest' + +// --------------------------------------------------------------------------- +// Mock the controller so no real service / DB / rate-limiter code runs. +// Each handler simply calls next() or sends 200 so we can focus on the +// middleware under test (body-size limit and CORS). +// jest.resetModules() preserves jest.mock() factory registrations, so this +// single top-level mock is picked up by every fresh require('./index'). +// --------------------------------------------------------------------------- +jest.mock('../../controllers/mcp-endpoint', () => ({ + __esModule: true, + default: { + getRateLimiterMiddleware: (_req: Request, _res: Response, next: () => void) => next(), + authenticateToken: (_req: Request, _res: Response, next: () => void) => next(), + handlePost: (_req: Request, res: Response) => res.status(200).json({ ok: true }), + handleGet: (_req: Request, res: Response) => res.status(200).end(), + handleSseMessage: (_req: Request, res: Response) => res.status(200).json({ ok: true }), + handleDelete: (_req: Request, res: Response) => res.status(200).json({ ok: true }) + } +})) + +// --------------------------------------------------------------------------- +// Helper: build a fresh Express app with a freshly-required router so that +// process.env changes between describe blocks are picked up. +// MCP_CORS_ORIGINS is read at module-load time, so the module must be +// re-required after every env change. +// --------------------------------------------------------------------------- +function buildApp(): express.Application { + jest.resetModules() + const mcpRouter = require('./index').default + const app = express() + app.use('/mcp', mcpRouter) + return app +} + +// --------------------------------------------------------------------------- +// Body size limit +// Express uses the `bytes` package where '1mb' = 1,048,576 bytes (binary MiB). +// --------------------------------------------------------------------------- +describe('body size limit', () => { + let app: express.Application + + beforeAll(() => { + delete process.env.MCP_CORS_ORIGINS + app = buildApp() + }) + + it('accepts a payload just under 1 MiB (500 KB)', async () => { + const payload = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { data: 'x'.repeat(500_000) } }) + const res = await request(app).post('/mcp/test-flow').set('Content-Type', 'application/json').send(payload) + + expect(res.status).not.toBe(413) + }) + + it('rejects a payload over 1 MiB (2 MB) with 413', async () => { + const payload = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { data: 'x'.repeat(2_000_000) } }) + const res = await request(app).post('/mcp/test-flow').set('Content-Type', 'application/json').send(payload) + + expect(res.status).toBe(413) + }) + + it('rejects a payload clearly over 1 MiB (~1.1 MB) with 413', async () => { + // 1mb in Express (bytes package) = 1,048,576 bytes; 1,100,000 bytes of data + // produces a JSON body well above that threshold. + const payload = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { data: 'x'.repeat(1_100_000) } }) + const res = await request(app).post('/mcp/test-flow').set('Content-Type', 'application/json').send(payload) + + expect(res.status).toBe(413) + }) +}) + +// --------------------------------------------------------------------------- +// CORS — MCP_CORS_ORIGINS unset +// origin callback: allow when no Origin header (desktop clients), block otherwise. +// When blocked, cors calls next() without setting ACAO; the handler still runs +// and returns 200, but the browser enforces the block on the missing header. +// --------------------------------------------------------------------------- +describe('CORS — MCP_CORS_ORIGINS unset', () => { + let app: express.Application + + beforeAll(() => { + delete process.env.MCP_CORS_ORIGINS + app = buildApp() + }) + + it('allows requests with no Origin header (desktop / server-to-server clients)', async () => { + const res = await request(app) + .post('/mcp/test-flow') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })) + + expect(res.status).toBe(200) + }) + + it('does not set ACAO header for browser requests (unlisted origin)', async () => { + const res = await request(app) + .post('/mcp/test-flow') + .set('Content-Type', 'application/json') + .set('Origin', 'https://evil.example.com') + .send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })) + + // cors calls next() without ACAO; browser would block based on header absence + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('does not set ACAO header for preflight from browser origin', async () => { + const res = await request(app) + .options('/mcp/test-flow') + .set('Origin', 'https://evil.example.com') + .set('Access-Control-Request-Method', 'POST') + + // cors calls next() → Express default OPTIONS handler responds (no cors headers) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// CORS — MCP_CORS_ORIGINS=* +// Configured as origin: true, which causes cors to echo the request Origin +// back as ACAO (not the literal string '*'). +// --------------------------------------------------------------------------- +describe('CORS — MCP_CORS_ORIGINS=*', () => { + let app: express.Application + + beforeAll(() => { + process.env.MCP_CORS_ORIGINS = '*' + app = buildApp() + }) + + afterAll(() => { + delete process.env.MCP_CORS_ORIGINS + }) + + it('allows any browser origin and echoes it as ACAO', async () => { + const res = await request(app) + .post('/mcp/test-flow') + .set('Content-Type', 'application/json') + .set('Origin', 'https://any.example.com') + .send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })) + + expect(res.status).toBe(200) + // origin: true → cors echoes the request origin (not the literal string '*') + expect(res.headers['access-control-allow-origin']).toBe('https://any.example.com') + }) + + it('responds to preflight with 204 and echoes origin as ACAO', async () => { + const res = await request(app) + .options('/mcp/test-flow') + .set('Origin', 'https://any.example.com') + .set('Access-Control-Request-Method', 'POST') + + expect(res.status).toBe(204) + expect(res.headers['access-control-allow-origin']).toBe('https://any.example.com') + }) +}) + +// --------------------------------------------------------------------------- +// CORS — MCP_CORS_ORIGINS=specific list +// --------------------------------------------------------------------------- +describe('CORS — MCP_CORS_ORIGINS specific list', () => { + let app: express.Application + + beforeAll(() => { + process.env.MCP_CORS_ORIGINS = 'https://allowed.example.com, https://also-allowed.example.com' + app = buildApp() + }) + + afterAll(() => { + delete process.env.MCP_CORS_ORIGINS + }) + + // NOTE: ACAO Header - Access-Control-Allow-Origin: + it('allows a listed origin and sets ACAO to that origin', async () => { + const res = await request(app) + .post('/mcp/test-flow') + .set('Content-Type', 'application/json') + .set('Origin', 'https://allowed.example.com') + .send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })) + + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBe('https://allowed.example.com') + }) + + it('allows the second listed origin and sets ACAO to that origin', async () => { + const res = await request(app) + .post('/mcp/test-flow') + .set('Content-Type', 'application/json') + .set('Origin', 'https://also-allowed.example.com') + .send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })) + + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBe('https://also-allowed.example.com') + }) + + it('does not set ACAO header for an unlisted origin', async () => { + const res = await request(app) + .post('/mcp/test-flow') + .set('Content-Type', 'application/json') + .set('Origin', 'https://evil.example.com') + .send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })) + + // cors omits ACAO for rejected origins; browser blocks client-side + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('responds to preflight with 204 and ACAO for a listed origin', async () => { + const res = await request(app) + .options('/mcp/test-flow') + .set('Origin', 'https://allowed.example.com') + .set('Access-Control-Request-Method', 'POST') + + expect(res.status).toBe(204) + expect(res.headers['access-control-allow-origin']).toBe('https://allowed.example.com') + }) + + it('does not set ACAO header for preflight from an unlisted origin', async () => { + const res = await request(app) + .options('/mcp/test-flow') + .set('Origin', 'https://evil.example.com') + .set('Access-Control-Request-Method', 'POST') + + // cors still handles the OPTIONS and responds 204 (array-based origin), + // but does not set ACAO since the origin is not in the allow-list + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) +}) diff --git a/packages/server/src/routes/mcp-endpoint/index.ts b/packages/server/src/routes/mcp-endpoint/index.ts new file mode 100644 index 00000000000..e8b6f764e45 --- /dev/null +++ b/packages/server/src/routes/mcp-endpoint/index.ts @@ -0,0 +1,45 @@ +import express from 'express' +import cors from 'cors' +import mcpEndpointController from '../../controllers/mcp-endpoint' + +const router = express.Router() + +// Body size limit: 1MB max for MCP JSON-RPC payloads (overrides the global 50mb limit) +router.use(express.json({ limit: '1mb', type: 'application/json' })) + +// CORS: Use MCP_CORS_ORIGINS if set, otherwise allow only non-browser (no Origin header) requests. +// MCP desktop clients (Claude Desktop, Cursor, etc.) don't send an Origin header, so they pass through. +// Browser-based clients are restricted to the configured origins. +const mcpCorsOrigins = process.env.MCP_CORS_ORIGINS +const mcpCorsOptions: cors.CorsOptions = { + origin: mcpCorsOrigins + ? mcpCorsOrigins === '*' + ? true + : mcpCorsOrigins.split(',').map((o) => o.trim()) + : (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + // No origin header (desktop/server-to-server) → allow + // Browser origin → deny (no allowed list configured) + callback(null, !origin) + }, + methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + maxAge: 86400 +} +router.use(cors(mcpCorsOptions)) +// Handle preflight for all MCP routes +router.options('/:chatflowId', cors(mcpCorsOptions)) + +// MCP Streamable HTTP protocol routes (protocol version 2025-03-26) +// Auth: token must be provided via Authorization: Bearer header +// POST — JSON-RPC messages (initialize, tools/list, tools/call, etc.) +router.post( + '/:chatflowId', + mcpEndpointController.getRateLimiterMiddleware, + mcpEndpointController.authenticateToken, + mcpEndpointController.handlePost +) + +// DELETE — Session termination (stateless mode returns 405) +router.delete('/:chatflowId', mcpEndpointController.handleDelete) + +export default router diff --git a/packages/server/src/routes/mcp-server/index.ts b/packages/server/src/routes/mcp-server/index.ts new file mode 100644 index 00000000000..daf802ab60b --- /dev/null +++ b/packages/server/src/routes/mcp-server/index.ts @@ -0,0 +1,21 @@ +import express from 'express' +import mcpServerController from '../../controllers/mcp-server' +import { checkAnyPermission } from '../../enterprise/rbac/PermissionCheck' +const router = express.Router() + +// GET /api/v1/mcp-server/:id → get current config +router.get('/:id', checkAnyPermission('chatflows:config,agentflows:config'), mcpServerController.getMcpServerConfig) + +// POST /api/v1/mcp-server/:id → enable (generates token) +router.post('/:id', checkAnyPermission('chatflows:config,agentflows:config'), mcpServerController.createMcpServerConfig) + +// PUT /api/v1/mcp-server/:id → update description/toolName/status +router.put('/:id', checkAnyPermission('chatflows:config,agentflows:config'), mcpServerController.updateMcpServerConfig) + +// DELETE /api/v1/mcp-server/:id → disable (set enabled=false) +router.delete('/:id', checkAnyPermission('chatflows:config,agentflows:config'), mcpServerController.deleteMcpServerConfig) + +// POST /api/v1/mcp-server/:id/refresh → rotate token +router.post('/:id/refresh', checkAnyPermission('chatflows:config,agentflows:config'), mcpServerController.refreshMcpToken) + +export default router diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts index 697b757d6c7..844288d0848 100644 --- a/packages/server/src/services/chatflows/index.ts +++ b/packages/server/src/services/chatflows/index.ts @@ -54,7 +54,7 @@ const checkIfChatflowIsValidForStreaming = async (chatflowId: string): Promise workspace.id) - const chatflowsCount = await appServer.AppDataSource.getRepository(ChatFlow).countBy({ - type, - workspaceId: In(workspaceIds) - }) + let chatflowsCount: number + if (type === 'AGENT') { + // Count both ASSISTANT (legacy) and AGENT (new) types + chatflowsCount = await appServer.AppDataSource.getRepository(ChatFlow).countBy([ + { type: 'ASSISTANT' as ChatflowType, workspaceId: In(workspaceIds) }, + { type: 'AGENT' as ChatflowType, workspaceId: In(workspaceIds) } + ]) + } else { + chatflowsCount = await appServer.AppDataSource.getRepository(ChatFlow).countBy({ + type, + workspaceId: In(workspaceIds) + }) + } return chatflowsCount } catch (error) { @@ -204,6 +216,14 @@ const getAllChatflowsCount = async (type?: ChatflowType, workspaceId?: string): try { const appServer = getRunningExpressApp() if (type) { + if (type === 'AGENT') { + // Count both ASSISTANT (legacy) and AGENT (new) types + const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow).countBy([ + { type: 'ASSISTANT' as ChatflowType, ...getWorkspaceSearchOptions(workspaceId) }, + { type: 'AGENT' as ChatflowType, ...getWorkspaceSearchOptions(workspaceId) } + ]) + return dbResponse + } const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow).countBy({ type, ...getWorkspaceSearchOptions(workspaceId) diff --git a/packages/server/src/services/evaluations/index.ts b/packages/server/src/services/evaluations/index.ts index 1acbfc3b894..907a4e101e6 100644 --- a/packages/server/src/services/evaluations/index.ts +++ b/packages/server/src/services/evaluations/index.ts @@ -509,7 +509,7 @@ const isOutdated = async (id: string, workspaceId: string) => { // check for backward compatibility, as previous versions did not the types in additionalConfig if (chatflowTypes && chatflowTypes.length >= 0) { if (chatflowTypes[i] === 'Custom Assistant') { - // if the chatflow type is custom assistant, then we should NOT check in the chatflows table + // Legacy custom assistant records are in the assistant table, skip chatflow table check continue } } @@ -530,7 +530,12 @@ const isOutdated = async (id: string, workspaceId: string) => { returnObj.chatflows.push({ chatflowName: chatflowNames[i], chatflowId: chatflowIds[i], - chatflowType: chatflow.type === 'AGENTFLOW' ? 'Agentflow v2' : 'Chatflow', + chatflowType: + chatflow.type === 'AGENTFLOW' + ? 'Agentflow v2' + : chatflow.type === 'AGENT' || chatflow.type === 'ASSISTANT' + ? 'Agent' + : 'Chatflow', isOutdated: true }) } @@ -539,7 +544,7 @@ const isOutdated = async (id: string, workspaceId: string) => { if (chatflowTypes && chatflowTypes.length > 0) { for (let i = 0; i < chatflowIds.length; i++) { if (chatflowTypes[i] !== 'Custom Assistant') { - // if the chatflow type is NOT custom assistant, then bail out for this item + // Only check assistant table for legacy Custom Assistant records continue } const assistant = await appServer.AppDataSource.getRepository(Assistant).findOneBy({ @@ -548,7 +553,7 @@ const isOutdated = async (id: string, workspaceId: string) => { }) if (!assistant) { returnObj.errors.push({ - message: `Custom Assistant ${chatflowNames[i]} not found`, + message: `Agent ${chatflowNames[i]} not found`, id: chatflowIds[i] }) isOutdated = true @@ -559,7 +564,7 @@ const isOutdated = async (id: string, workspaceId: string) => { returnObj.chatflows.push({ chatflowName: chatflowNames[i], chatflowId: chatflowIds[i], - chatflowType: 'Custom Assistant', + chatflowType: 'Agent', isOutdated: true }) } diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts index 3e729bb5fa6..16f6e34042d 100644 --- a/packages/server/src/services/export-import/index.ts +++ b/packages/server/src/services/export-import/index.ts @@ -105,7 +105,7 @@ const exportData = async (exportInput: ExportInput, activeWorkspaceId: string): exportInput.assistantCustom === true ? await assistantsService.getAllAssistants(activeWorkspaceId, 'CUSTOM') : [] let AssistantFlow: ChatFlow[] | { data: ChatFlow[]; total: number } = - exportInput.assistantCustom === true ? await chatflowService.getAllChatflows('ASSISTANT', activeWorkspaceId) : [] + exportInput.assistantCustom === true ? await chatflowService.getAllChatflows('AGENT', activeWorkspaceId) : [] AssistantFlow = 'data' in AssistantFlow ? AssistantFlow.data : AssistantFlow let AssistantOpenAI: Assistant[] = @@ -683,7 +683,7 @@ const importData = async (importData: ExportData, orgId: string, activeWorkspace if (importData.AssistantFlow.length > 0) { importData.AssistantFlow = reduceSpaceForChatflowFlowData(importData.AssistantFlow) importData.AssistantFlow = insertWorkspaceId(importData.AssistantFlow, activeWorkspaceId) - const existingChatflowCount = await chatflowService.getAllChatflowsCountByOrganization('ASSISTANT', orgId) + const existingChatflowCount = await chatflowService.getAllChatflowsCountByOrganization('AGENT', orgId) const newChatflowCount = importData.AssistantFlow.length await checkUsageLimit( 'flows', @@ -949,6 +949,8 @@ const getChatType = (chatType?: ChatType): string => { return 'UI' case ChatType.EXTERNAL: return 'API/Embed' + case ChatType.MCP: + return 'MCP' } } diff --git a/packages/server/src/services/marketplaces/index.ts b/packages/server/src/services/marketplaces/index.ts index 80482868b0c..c23f93be209 100644 --- a/packages/server/src/services/marketplaces/index.ts +++ b/packages/server/src/services/marketplaces/index.ts @@ -11,6 +11,7 @@ import { getErrorMessage } from '../../errors/utils' import { IReactFlowEdge, IReactFlowNode } from '../../Interface' import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { stripProtectedFields } from '../../utils/stripProtectedFields' +import logger from '../../utils/logger' import chatflowsService from '../chatflows' type ITemplate = { @@ -33,41 +34,49 @@ const getAllTemplates = async () => { let jsonsInDir = fs.readdirSync(marketplaceDir).filter((file) => path.extname(file) === '.json') let templates: any[] = [] jsonsInDir.forEach((file) => { - const filePath = path.join(__dirname, '..', '..', '..', 'marketplaces', 'chatflows', file) - const fileData = fs.readFileSync(filePath) - const fileDataObj = JSON.parse(fileData.toString()) as ITemplate + try { + const filePath = path.join(__dirname, '..', '..', '..', 'marketplaces', 'chatflows', file) + const fileData = fs.readFileSync(filePath) + const fileDataObj = JSON.parse(fileData.toString()) as ITemplate - const template = { - id: uuidv4(), - templateName: file.split('.json')[0], - flowData: fileData.toString(), - badge: fileDataObj?.badge, - framework: fileDataObj?.framework, - usecases: fileDataObj?.usecases, - categories: getCategories(fileDataObj), - type: 'Chatflow', - description: fileDataObj?.description || '' + const template = { + id: uuidv4(), + templateName: file.split('.json')[0], + flowData: fileData.toString(), + badge: fileDataObj?.badge, + framework: fileDataObj?.framework, + usecases: fileDataObj?.usecases, + categories: getCategories(fileDataObj), + type: 'Chatflow', + description: fileDataObj?.description || '' + } + templates.push(template) + } catch (e) { + logger.warn(`[server]: Skipping invalid chatflow template file ${file}: ${getErrorMessage(e)}`) } - templates.push(template) }) marketplaceDir = path.join(__dirname, '..', '..', '..', 'marketplaces', 'tools') jsonsInDir = fs.readdirSync(marketplaceDir).filter((file) => path.extname(file) === '.json') jsonsInDir.forEach((file) => { - const filePath = path.join(__dirname, '..', '..', '..', 'marketplaces', 'tools', file) - const fileData = fs.readFileSync(filePath) - const fileDataObj = JSON.parse(fileData.toString()) - const template = { - ...fileDataObj, - id: uuidv4(), - type: 'Tool', - framework: fileDataObj?.framework, - badge: fileDataObj?.badge, - usecases: fileDataObj?.usecases, - categories: [], - templateName: file.split('.json')[0] + try { + const filePath = path.join(__dirname, '..', '..', '..', 'marketplaces', 'tools', file) + const fileData = fs.readFileSync(filePath) + const fileDataObj = JSON.parse(fileData.toString()) + const template = { + ...fileDataObj, + id: uuidv4(), + type: 'Tool', + framework: fileDataObj?.framework, + badge: fileDataObj?.badge, + usecases: fileDataObj?.usecases, + categories: [], + templateName: file.split('.json')[0] + } + templates.push(template) + } catch (e) { + logger.warn(`[server]: Skipping invalid tool template file ${file}: ${getErrorMessage(e)}`) } - templates.push(template) }) /* @@ -95,37 +104,59 @@ const getAllTemplates = async () => { marketplaceDir = path.join(__dirname, '..', '..', '..', 'marketplaces', 'agentflowsv2') jsonsInDir = fs.readdirSync(marketplaceDir).filter((file) => path.extname(file) === '.json') jsonsInDir.forEach((file) => { - const filePath = path.join(__dirname, '..', '..', '..', 'marketplaces', 'agentflowsv2', file) - const fileData = fs.readFileSync(filePath) - const fileDataObj = JSON.parse(fileData.toString()) - const template = { - id: uuidv4(), - templateName: file.split('.json')[0], - flowData: fileData.toString(), - badge: fileDataObj?.badge, - framework: fileDataObj?.framework, - usecases: fileDataObj?.usecases, - categories: getCategories(fileDataObj), - type: 'AgentflowV2', - description: fileDataObj?.description || '' + try { + const filePath = path.join(__dirname, '..', '..', '..', 'marketplaces', 'agentflowsv2', file) + const fileData = fs.readFileSync(filePath) + const fileDataObj = JSON.parse(fileData.toString()) + const template = { + id: uuidv4(), + templateName: file.split('.json')[0], + flowData: fileData.toString(), + badge: fileDataObj?.badge, + framework: fileDataObj?.framework, + usecases: fileDataObj?.usecases, + categories: getCategories(fileDataObj), + type: 'AgentflowV2', + description: fileDataObj?.description || '' + } + templates.push(template) + } catch (e) { + logger.warn(`[server]: Skipping invalid agentflow template file ${file}: ${getErrorMessage(e)}`) } - templates.push(template) }) + // Scan Agent templates + const agentsDir = path.join(__dirname, '..', '..', '..', 'marketplaces', 'agents') + if (fs.existsSync(agentsDir)) { + const agentJsons = fs.readdirSync(agentsDir).filter((file) => path.extname(file) === '.json') + agentJsons.forEach((file) => { + try { + const filePath = path.join(agentsDir, file) + const fileData = fs.readFileSync(filePath) + const fileDataObj = JSON.parse(fileData.toString()) + const template = { + id: uuidv4(), + templateName: file.split('.json')[0], + flowData: fileData.toString(), + badge: fileDataObj?.badge, + framework: fileDataObj?.framework, + usecases: fileDataObj?.usecases, + categories: getCategories(fileDataObj), + type: 'Agent', + description: fileDataObj?.description || '' + } + templates.push(template) + } catch (e) { + logger.warn(`[server]: Skipping invalid agent template file ${file}: ${getErrorMessage(e)}`) + } + }) + } + const sortedTemplates = templates.sort((a, b) => { - // Prioritize AgentflowV2 templates first - if (a.type === 'AgentflowV2' && b.type !== 'AgentflowV2') { - return -1 - } - if (b.type === 'AgentflowV2' && a.type !== 'AgentflowV2') { - return 1 - } - // Put Tool templates last - if (a.type === 'Tool' && b.type !== 'Tool') { - return 1 - } - if (b.type === 'Tool' && a.type !== 'Tool') { - return -1 - } + // Prioritize Agent and AgentflowV2 templates first + const priority: Record = { Agent: 0, AgentflowV2: 1, Chatflow: 2, Agentflow: 3, Tool: 4 } + const aPriority = priority[a.type] ?? 3 + const bPriority = priority[b.type] ?? 3 + if (aPriority !== bPriority) return aPriority - bPriority // For same types, sort alphabetically by templateName return a.templateName.localeCompare(b.templateName) }) diff --git a/packages/server/src/services/mcp-endpoint/index.test.ts b/packages/server/src/services/mcp-endpoint/index.test.ts new file mode 100644 index 00000000000..749c31145f9 --- /dev/null +++ b/packages/server/src/services/mcp-endpoint/index.test.ts @@ -0,0 +1,535 @@ +/** + * Unit tests for MCP endpoint service (packages/server/src/services/mcp-endpoint/index.ts) + * + * Tests the service layer in isolation: auth error forwarding, config validation, + * stateless request handling, and the internal chatflow tool callback + * (result extraction + error handling). + * + * Controller tests (controllers/mcp-endpoint/index.test.ts) already cover the + * Express middleware layer; these tests focus exclusively on the service functions + * that are NOT exercised there. + */ + +// --- Mock: typeorm decorators (virtual: true for pnpm resolution) --- +jest.mock( + 'typeorm', + () => ({ + Entity: () => (_target: any) => _target, + Column: () => () => {}, + CreateDateColumn: () => () => {}, + UpdateDateColumn: () => () => {}, + PrimaryGeneratedColumn: () => () => {} + }), + { virtual: true } +) + +// --- Mock: uuid (ESM module — must be mocked before import) --- +jest.mock('uuid', () => ({ + v4: () => 'mock-uuid-v4' +})) + +// --- Mock: chat-messages service (prevents @langchain/core transitive import) --- +jest.mock('../../services/chat-messages', () => ({ + __esModule: true, + default: { + abortChatMessage: jest.fn().mockResolvedValue(undefined) + } +})) + +// --- Mock: mcp-server service --- +const mockGetChatflowByIdAndVerifyToken = jest.fn() +const mockParseMcpConfig = jest.fn() + +jest.mock('../mcp-server/index', () => ({ + __esModule: true, + default: { + getChatflowByIdAndVerifyToken: (...args: any[]) => mockGetChatflowByIdAndVerifyToken(...args), + parseMcpConfig: (...args: any[]) => mockParseMcpConfig(...args) + } +})) + +// --- Mock: utilities --- +const mockUtilBuildChatflow = jest.fn() +jest.mock('../../utils/buildChatflow', () => ({ + utilBuildChatflow: (...args: any[]) => mockUtilBuildChatflow(...args) +})) + +const mockCreateMockRequest = jest.fn() +jest.mock('../../utils/mockRequest', () => ({ + createMockRequest: (...args: any[]) => mockCreateMockRequest(...args) +})) + +jest.mock('../../utils/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } +})) + +// --- Mock: MCP SDK --- +const mockMcpTool = jest.fn() +const mockMcpConnect = jest.fn().mockResolvedValue(undefined) +const mockMcpClose = jest.fn().mockResolvedValue(undefined) + +jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ + McpServer: jest.fn().mockImplementation(() => ({ + tool: mockMcpTool, + connect: mockMcpConnect, + close: mockMcpClose + })) +})) + +const mockHandleRequest = jest.fn().mockResolvedValue(undefined) +jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ + StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({ + handleRequest: mockHandleRequest + })) +})) + +// --- Import after mocking --- +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import mcpEndpointService from '.' + +// --- Helpers --- + +function makeChatflow(overrides: Record = {}) { + return { + id: 'flow-123', + name: 'Test Chatflow', + type: 'CHATFLOW', + workspaceId: 'ws-1', + mcpServerConfig: undefined as string | undefined, + ...overrides + } +} + +function makeConfig(overrides: Record = {}) { + return { + enabled: true, + token: 'a'.repeat(32), + description: 'Test description', + toolName: 'test_tool', + ...overrides + } +} + +function makeReq(overrides: Record = {}) { + return { + params: { chatflowId: 'flow-123' }, + headers: {}, + query: {}, + body: { question: 'hello' }, + get: jest.fn(), + ...overrides + } +} + +/** Creates a mock Response that captures 'close' event handlers and can fire them. */ +function makeRes() { + const closeHandlers: Function[] = [] + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + locals: {}, + on: jest.fn().mockImplementation((event: string, handler: Function) => { + if (event === 'close') closeHandlers.push(handler) + }), + triggerClose: () => closeHandlers.forEach((fn) => fn()) + } + return res +} + +// --- Global beforeEach --- +beforeEach(() => { + jest.clearAllMocks() + + // Auth: default success + const chatflow = makeChatflow({ mcpServerConfig: JSON.stringify(makeConfig()) }) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + mockParseMcpConfig.mockReturnValue(makeConfig()) + + // Default chatflow build result + mockUtilBuildChatflow.mockResolvedValue('chatflow answer') + mockCreateMockRequest.mockReturnValue({ mocked: true }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// handleMcpRequest (stateless Streamable HTTP) +// ───────────────────────────────────────────────────────────────────────────── +describe('handleMcpRequest', () => { + it('returns 401 when getChatflowByIdAndVerifyToken throws UNAUTHORIZED', async () => { + mockGetChatflowByIdAndVerifyToken.mockRejectedValue(new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid token')) + const res = makeRes() + + await mcpEndpointService.handleMcpRequest('flow-123', 'bad-token', makeReq() as any, res) + + expect(res.status).toHaveBeenCalledWith(401) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ jsonrpc: '2.0', error: expect.objectContaining({ code: -32001 }) })) + expect(mockHandleRequest).not.toHaveBeenCalled() + }) + + it('returns 404 when getChatflowByIdAndVerifyToken throws NOT_FOUND', async () => { + mockGetChatflowByIdAndVerifyToken.mockRejectedValue(new InternalFlowiseError(StatusCodes.NOT_FOUND, 'Not found')) + const res = makeRes() + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, res) + + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ jsonrpc: '2.0', error: expect.objectContaining({ code: -32001 }) })) + }) + + it('rethrows non-InternalFlowiseError auth errors', async () => { + const dbError = new Error('DB connection failed') + mockGetChatflowByIdAndVerifyToken.mockRejectedValue(dbError) + + await expect(mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes())).rejects.toThrow( + 'DB connection failed' + ) + }) + + it('returns 404 when config is null', async () => { + mockParseMcpConfig.mockReturnValue(null) + const res = makeRes() + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, res) + + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ jsonrpc: '2.0', error: expect.objectContaining({ code: -32001 }) })) + }) + + it('returns 404 when config.enabled is false', async () => { + mockParseMcpConfig.mockReturnValue(makeConfig({ enabled: false })) + const res = makeRes() + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, res) + + expect(res.status).toHaveBeenCalledWith(404) + }) + + it('calls mcpServer.connect and transport.handleRequest on success', async () => { + const req = makeReq() as any + const res = makeRes() + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', req, res) + + expect(mockMcpConnect).toHaveBeenCalledTimes(1) + expect(mockHandleRequest).toHaveBeenCalledWith(req, res, req.body) + }) + + it('registers the tool with toolName and description from config', async () => { + mockParseMcpConfig.mockReturnValue(makeConfig({ toolName: 'my_flow_tool', description: 'My Flow' })) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + expect(mockMcpTool).toHaveBeenCalledWith('my_flow_tool', 'My Flow', expect.any(Object), expect.any(Function)) + }) + + it('sanitizes chatflow name as toolName when config has no toolName', async () => { + mockParseMcpConfig.mockReturnValue(makeConfig({ toolName: undefined })) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(makeChatflow({ name: 'My Complex Flow! V2' })) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const registeredName = mockMcpTool.mock.calls[0][0] as string + expect(registeredName).toMatch(/^[a-z0-9_-]+$/) + expect(registeredName).not.toMatch(/^_|_$/) + expect(registeredName).toBe('my_complex_flow_v2') + }) + + it('uses default description when config.description is absent', async () => { + const chatflow = makeChatflow({ name: 'My Chatflow' }) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + mockParseMcpConfig.mockReturnValue(makeConfig({ description: undefined })) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const registeredDesc = mockMcpTool.mock.calls[0][1] as string + expect(registeredDesc).toContain('My Chatflow') + }) + + it('sets up res.on("close") to close the MCP server', async () => { + const res = makeRes() + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, res) + + expect(res.on).toHaveBeenCalledWith('close', expect.any(Function)) + // Firing close should call mcpServer.close + res.triggerClose() + expect(mockMcpClose).toHaveBeenCalled() + }) + + describe('input schema selection', () => { + it('uses optional question schema for AGENTFLOW type', async () => { + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(makeChatflow({ type: 'AGENTFLOW' })) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + expect(Object.keys(schema)).toContain('question') + }) + + it('uses mandatory question-only schema for CHATFLOW type', async () => { + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(makeChatflow({ type: 'CHATFLOW' })) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + expect(Object.keys(schema)).not.toContain('form') + expect(Object.keys(schema)).toContain('question') + }) + }) + + describe('buildFormInputSchema', () => { + function makeAgentflowWithFormInputs(formInputTypes: any[]) { + return makeChatflow({ + type: 'AGENTFLOW', + flowData: JSON.stringify({ + nodes: [ + { + data: { + name: 'startAgentflow', + inputs: { + startInputType: 'formInput', + formInputTypes + } + } + } + ], + edges: [] + }) + }) + } + + it('generates a form schema with string fields', async () => { + const chatflow = makeAgentflowWithFormInputs([{ type: 'string', label: 'Name', name: 'name', addOptions: '' }]) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + expect(Object.keys(schema)).toEqual(['form']) + // The form field should be a zod object with a 'name' key in its shape + expect(schema.form._def.typeName).toBe('ZodObject') + expect(schema.form.shape).toHaveProperty('name') + expect(schema.form.shape.name._def.typeName).toBe('ZodString') + }) + + it('generates a form schema with number fields', async () => { + const chatflow = makeAgentflowWithFormInputs([{ type: 'number', label: 'Age', name: 'age', addOptions: '' }]) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + expect(schema.form.shape).toHaveProperty('age') + expect(schema.form.shape.age._def.typeName).toBe('ZodNumber') + }) + + it('generates a form schema with boolean fields', async () => { + const chatflow = makeAgentflowWithFormInputs([{ type: 'boolean', label: 'Adult', name: 'is_adult', addOptions: '' }]) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + expect(schema.form.shape).toHaveProperty('is_adult') + expect(schema.form.shape.is_adult._def.typeName).toBe('ZodBoolean') + }) + + it('generates a form schema with options (enum) fields', async () => { + const chatflow = makeAgentflowWithFormInputs([ + { type: 'options', label: 'Favorite Drink', name: 'favorite_drink', addOptions: [{ option: 'Tea' }, { option: 'Coffee' }] } + ]) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + expect(schema.form.shape).toHaveProperty('favorite_drink') + expect(schema.form.shape.favorite_drink._def.typeName).toBe('ZodEnum') + expect(schema.form.shape.favorite_drink._def.values).toEqual(['Tea', 'Coffee']) + }) + + it('generates a combined schema with multiple field types', async () => { + const chatflow = makeAgentflowWithFormInputs([ + { type: 'string', label: 'Name', name: 'name', addOptions: '' }, + { type: 'number', label: 'Age', name: 'age', addOptions: '' }, + { type: 'boolean', label: 'Adult', name: 'is_adult', addOptions: '' }, + { type: 'options', label: 'Favorite Drink', name: 'favorite_drink', addOptions: [{ option: 'Tea' }, { option: 'Coffee' }] } + ]) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + const shapeKeys = Object.keys(schema.form.shape) + expect(shapeKeys).toEqual(['name', 'age', 'is_adult', 'favorite_drink']) + expect(schema.form.shape.name._def.typeName).toBe('ZodString') + expect(schema.form.shape.age._def.typeName).toBe('ZodNumber') + expect(schema.form.shape.is_adult._def.typeName).toBe('ZodBoolean') + expect(schema.form.shape.favorite_drink._def.typeName).toBe('ZodEnum') + }) + + it('attaches label as description on each field', async () => { + const chatflow = makeAgentflowWithFormInputs([{ type: 'string', label: 'Full Name', name: 'full_name', addOptions: '' }]) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + + const schema = mockMcpTool.mock.calls[0][2] as Record + expect(schema.form.shape.full_name.description).toBe('Full Name') + }) + + it('throws an error when form input configuration is invalid', async () => { + const chatflow = makeAgentflowWithFormInputs([{ type: 'unknown', label: 'Field', name: 'field', addOptions: '' }]) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(chatflow) + + await expect(mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes())).rejects.toThrow( + 'Failed to build form input schema due to invalid configuration' + ) + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Chatflow tool callback (chatflowCallback — called when MCP tool is invoked) +// ───────────────────────────────────────────────────────────────────────────── +describe('chatflow tool callback', () => { + let toolCallback: Function + + beforeEach(async () => { + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + // The callback is the 4th argument passed to mcpServer.tool(name, desc, schema, callback) + toolCallback = mockMcpTool.mock.calls[0][3] + }) + + it('returns a string result directly as text content', async () => { + mockUtilBuildChatflow.mockResolvedValue('hello world') + + const result = await toolCallback({ question: 'what?' }) + + expect(result).toEqual({ content: [{ type: 'text', text: 'hello world' }] }) + }) + + it('extracts result.text when the response has a text field', async () => { + mockUtilBuildChatflow.mockResolvedValue({ text: 'extracted answer' }) + + const result = await toolCallback({ question: 'what?' }) + + expect(result).toEqual({ content: [{ type: 'text', text: 'extracted answer' }] }) + }) + + it('stringifies result.json when the response has a json field', async () => { + const jsonPayload = { answer: 42, items: ['a', 'b'] } + mockUtilBuildChatflow.mockResolvedValue({ json: jsonPayload }) + + const result = await toolCallback({ question: 'what?' }) + + expect(result).toEqual({ content: [{ type: 'text', text: JSON.stringify(jsonPayload) }] }) + }) + + it('stringifies the full result object when no recognised fields are present', async () => { + const obj = { other: 'data', n: 1 } + mockUtilBuildChatflow.mockResolvedValue(obj) + + const result = await toolCallback({ question: 'what?' }) + + expect(result).toEqual({ content: [{ type: 'text', text: JSON.stringify(obj) }] }) + }) + + it('returns isError:true and a text message when utilBuildChatflow throws', async () => { + mockUtilBuildChatflow.mockRejectedValue(new Error('Build failed')) + + const result = await toolCallback({ question: 'what?' }) + + expect(result.isError).toBe(true) + expect(result.content).toHaveLength(1) + expect(result.content[0].type).toBe('text') + }) + + it('calls createMockRequest with the chatflowId and question', async () => { + mockUtilBuildChatflow.mockResolvedValue('ok') + + await toolCallback({ question: 'my question' }) + + expect(mockCreateMockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + chatflowId: 'flow-123', + body: expect.objectContaining({ question: 'my question' }) + }) + ) + }) + + it('includes form in the request body when args.form is provided', async () => { + // Re-establish the tool callback with an AGENTFLOW chatflow that uses formInput + const agentflowChatflow = makeChatflow({ + type: 'AGENTFLOW', + flowData: JSON.stringify({ + nodes: [ + { + data: { + name: 'startAgentflow', + inputs: { + startInputType: 'formInput', + formInputTypes: [ + { type: 'string', label: 'Name', name: 'name', addOptions: '' }, + { type: 'string', label: 'Age', name: 'age', addOptions: '' } + ] + } + } + } + ], + edges: [] + }) + }) + mockGetChatflowByIdAndVerifyToken.mockResolvedValue(agentflowChatflow) + await mcpEndpointService.handleMcpRequest('flow-123', 'token', makeReq() as any, makeRes()) + const formToolCallback = mockMcpTool.mock.calls[mockMcpTool.mock.calls.length - 1][3] + + mockUtilBuildChatflow.mockResolvedValue('ok') + const form = { name: 'Alice', age: '30' } + + await formToolCallback({ form }) + + expect(mockCreateMockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ form }) + }) + ) + }) + + it('omits form from the request body when args.form is absent', async () => { + mockUtilBuildChatflow.mockResolvedValue('ok') + + await toolCallback({ question: 'no form here' }) + + const callArgs = mockCreateMockRequest.mock.calls[0][0] + expect(callArgs.body).not.toHaveProperty('form') + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// handleMcpDeleteRequest +// ───────────────────────────────────────────────────────────────────────────── +describe('handleMcpDeleteRequest', () => { + it('always returns 405 (session termination not supported in stateless mode)', async () => { + const res = makeRes() + + await mcpEndpointService.handleMcpDeleteRequest('flow-123', makeReq() as any, res) + + expect(res.status).toHaveBeenCalledWith(405) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ code: -32000 }) + }) + ) + }) + + it('returns 405 regardless of chatflowId', async () => { + const res = makeRes() + + await mcpEndpointService.handleMcpDeleteRequest('any-flow-id', makeReq() as any, res) + + expect(res.status).toHaveBeenCalledWith(405) + }) +}) diff --git a/packages/server/src/services/mcp-endpoint/index.ts b/packages/server/src/services/mcp-endpoint/index.ts new file mode 100644 index 00000000000..0fb08b3cc27 --- /dev/null +++ b/packages/server/src/services/mcp-endpoint/index.ts @@ -0,0 +1,331 @@ +import { Request, Response } from 'express' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { z } from 'zod/v3' +import { v4 as uuidv4 } from 'uuid' +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { getErrorMessage } from '../../errors/utils' +import { utilBuildChatflow } from '../../utils/buildChatflow' +import { createMockRequest } from '../../utils/mockRequest' +import mcpServerService from '../mcp-server/index' +import { ChatFlow } from '../../database/entities/ChatFlow' +import logger from '../../utils/logger' +import { ChatType, IMcpServerConfig, IReactFlowObject } from '../../Interface' + +/** + * Build the MCP tool name from config + chatflow + */ +function getToolName(config: IMcpServerConfig, chatflow: ChatFlow): string { + if (config.toolName) return config.toolName + // Sanitize the chatflow name to be a valid tool identifier + return ( + chatflow.name + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .substring(0, 64) || 'chatflow_tool' + ) +} + +/** + * Build the MCP tool description from config + chatflow + */ +function getToolDescription(config: IMcpServerConfig, chatflow: ChatFlow): string { + if (config.description) return config.description + return `Execute the "${chatflow.name}" flow` +} + +/** + * Determine the tool input type based on the chatflow type and flowData. + * For AGENTFLOW, we look for a `startAgentflow` node and check its `startInputType` property. + * If it's `formInput`, we return 'form', otherwise 'question'. + * For other flow types, we default to 'question'. + */ +function getToolInputType(chatflow: ChatFlow): 'question' | 'form' { + if (chatflow.type === 'AGENTFLOW') { + try { + const flowData: IReactFlowObject = JSON.parse(chatflow.flowData) + const nodes = flowData.nodes || [] + const startNode = nodes.find((node) => node.data.name === 'startAgentflow') + const startInputType = startNode?.data?.inputs?.startInputType as 'chatInput' | 'formInput' + return startInputType === 'formInput' ? 'form' : 'question' + } catch (error) { + logger.error(`Failed to parse flowData for chatflow ${chatflow.id}: ${getErrorMessage(error)}`) + return 'question' + } + } + return 'question' +} + +/** + * Build the zod input schema parameters for the tool. + * For chatflows: always has a mandatory `question` string. + * For agentflows: only allow one of `question` or `form` (object) + */ +function buildInputSchema(chatflow: ChatFlow) { + const inputType = getToolInputType(chatflow) + if (inputType === 'form') { + return buildFormInputSchema(chatflow) + } + return { + question: z.string().describe('The question or prompt to send to the chatflow') + } +} + +/** + * Build the zod schema for form input, based on the `startAgentflow` node configuration. + * + * Example input: + * ```json + { + "inputs": { + "startInputType": "formInput", + "formInputTypes": [ + { + "type": "string", + "label": "Name", + "name": "name", + "addOptions": "" + }, + { + "type": "number", + "label": "Age", + "name": "age", + "addOptions": "" + }, + { + "type": "boolean", + "label": "Adult", + "name": "is_adult", + "addOptions": "" + }, + { + "type": "options", + "label": "Favorite Drink", + "name": "favorite_drink", + "addOptions": [ + { + "option": "Tea" + }, + { + "option": "Coffee" + } + ] + } + ] + } + } + ``` + */ +function buildFormInputSchema(chatflow: ChatFlow) { + try { + const flowData: IReactFlowObject = JSON.parse(chatflow.flowData) + const nodes = flowData.nodes || [] + const startNode = nodes.find((node) => node.data.name === 'startAgentflow') + const formInputTypes = startNode?.data?.inputs?.formInputTypes as + | { + type: string + label: string + name: string + addOptions: { option: string }[] + }[] + | undefined + + if (!formInputTypes || !Array.isArray(formInputTypes)) { + throw new Error('Invalid form input configuration in chatflow') + } + const schemaShape: Record = {} + formInputTypes.forEach((input) => { + switch (input.type) { + case 'string': + schemaShape[input.name] = z.string().describe(input.label) + break + case 'number': + schemaShape[input.name] = z.number().describe(input.label) + break + case 'boolean': + schemaShape[input.name] = z.boolean().describe(input.label) + break + case 'options': { + const options = input.addOptions.map((opt: { option: string }) => opt.option) || [] + schemaShape[input.name] = z.enum(options as [string, ...string[]]).describe(input.label) + break + } + default: + throw new Error(`Unsupported form input type: ${input.type}`) + } + }) + return { + form: z.object(schemaShape).describe('Form inputs for the agent flow') + } + } catch (error) { + logger.error(`Failed to build form input schema for chatflow ${chatflow.id}: ${getErrorMessage(error)}`) + // Fallback to a generic schema if there's an error + throw new Error('Failed to build form input schema due to invalid configuration') + } +} + +/** + * Callback function for MCP tool execution + * @return The tool response content, extracted from the utilBuildChatflow result + */ +async function chatflowCallback( + chatflow: ChatFlow, + chatId: string, + req: Request, + args: any +): Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }> { + const inputType = getToolInputType(chatflow) + const body = inputType === 'form' ? { form: args.form || {} } : { question: args.question || '' } + const mockReq = createMockRequest({ + chatflowId: chatflow.id, + body: { + ...body, + chatId + }, + sourceRequest: req + }) + + const result = await utilBuildChatflow(mockReq, true, ChatType.MCP) + + // Extract the text response from the result + let textContent: string + if (typeof result === 'string') { + textContent = result + } else if (result?.text) { + textContent = result.text + } else if (result?.json) { + textContent = JSON.stringify(result.json) + } else { + textContent = JSON.stringify(result) + } + + return { + content: [{ type: 'text' as const, text: textContent }] + } +} + +/** + * Handle an InternalFlowiseError from getChatflowByIdAndVerifyToken. + * Writes the appropriate JSON-RPC error response and returns true if handled. + * Returns false for unrecognised errors so the caller can rethrow. + */ +function handleServiceError(error: unknown, res: Response): boolean { + if (error instanceof InternalFlowiseError) { + if (error.statusCode === StatusCodes.UNAUTHORIZED) { + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null + }) + return true + } + if (error.statusCode === StatusCodes.NOT_FOUND) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'MCP server not found' }, + id: null + }) + return true + } + } + return false +} + +/** + * Handle an MCP protocol request (POST) for a given chatflowId + token. + * Uses the MCP SDK in stateless mode (no session management). + * The token is verified against the stored config (constant-time comparison). + */ +const handleMcpRequest = async (chatflowId: string, token: string, req: Request, res: Response): Promise => { + let chatflow: ChatFlow + let config: IMcpServerConfig | null + + try { + chatflow = await mcpServerService.getChatflowByIdAndVerifyToken(chatflowId, token) + config = mcpServerService.parseMcpConfig(chatflow) + } catch (error) { + if (handleServiceError(error, res)) return + throw error + } + + if (!config || !config.enabled) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'MCP server not found' }, + id: null + }) + return + } + + const toolName = getToolName(config, chatflow) + const toolDescription = getToolDescription(config, chatflow) + const inputSchema = buildInputSchema(chatflow) + + // Create a stateless MCP server for this request + const mcpServer = new McpServer( + { + name: `flowise-${toolName}`, + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ) + + mcpServer.tool(toolName, toolDescription, inputSchema as any, async (args: any) => { + try { + const chatId = uuidv4() // Generate a unique chat ID for this execution + return await chatflowCallback(chatflow, chatId, req, args) + } catch (error) { + const errorMessage = getErrorMessage(error) + logger.error(`[MCP] Error executing tool ${toolName} for chatflow ${chatflow.id}: ${errorMessage}`) + return { + content: [{ type: 'text' as const, text: 'An error occurred while executing the tool. Please try again later.' }], + isError: true + } + } + }) + + // Create a stateless transport (no session management) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }) + + // Connect server to transport + await mcpServer.connect(transport) + + // Clean up when the HTTP response finishes. + // NOTE: We must NOT close the server immediately after handleRequest() because + // the transport's handlePostRequest() fires onmessage() without awaiting it. + // If we close() too early, the SSE response stream is terminated before the + // McpServer has finished processing the request and writing its JSON-RPC response. + res.on('close', () => { + mcpServer.close().catch(() => {}) + }) + + // Handle the incoming request. + // The transport handles POST (JSON-RPC), GET (SSE), and DELETE (session). + await transport.handleRequest(req, res, req.body) +} + +/** + * Handle DELETE requests for session termination (stateless mode rejects with 405) + */ +const handleMcpDeleteRequest = async (chatflowId: string, req: Request, res: Response): Promise => { + // In stateless mode, DELETE is not applicable + res.status(405).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Session termination is not supported in stateless mode.' }, + id: null + }) +} + +export default { + handleMcpRequest, + handleMcpDeleteRequest +} diff --git a/packages/server/src/services/mcp-server/index.test.ts b/packages/server/src/services/mcp-server/index.test.ts new file mode 100644 index 00000000000..22ad9771fe8 --- /dev/null +++ b/packages/server/src/services/mcp-server/index.test.ts @@ -0,0 +1,332 @@ +/** + * Unit tests for MCP server service (packages/server/src/services/mcp-server/index.ts) + * + * These tests mock the database layer (getRunningExpressApp) and test the + * service functions in isolation: config CRUD, token generation/verification, + * toolName validation, and parseMcpConfig. + */ +import { StatusCodes } from 'http-status-codes' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' + +// Mock typeorm decorators before any entity import (virtual: true for pnpm resolution) +jest.mock( + 'typeorm', + () => ({ + Entity: () => (_target: any) => _target, + Column: () => () => {}, + CreateDateColumn: () => () => {}, + UpdateDateColumn: () => () => {}, + PrimaryGeneratedColumn: () => () => {} + }), + { virtual: true } +) + +// --- Mock setup --- +const mockFindOne = jest.fn() +const mockSave = jest.fn() + +jest.mock('../../utils/getRunningExpressApp', () => ({ + getRunningExpressApp: () => ({ + AppDataSource: { + getRepository: () => ({ + findOne: mockFindOne, + save: mockSave + }) + } + }) +})) + +// Import after mocking +import mcpServerService from '.' +import { IMcpServerConfig } from '../../Interface' + +// Helper: create a mock ChatFlow entity +function makeChatflow(overrides: Record = {}) { + return { + id: 'chatflow-1', + name: 'Test Chatflow', + flowData: '{}', + type: 'CHATFLOW', + workspaceId: 'ws-1', + mcpServerConfig: undefined as string | undefined, + ...overrides + } +} + +function makeConfig(overrides: Partial = {}): IMcpServerConfig { + return { + enabled: true, + token: 'a'.repeat(32), + description: 'Test tool', + toolName: 'test_tool', + ...overrides + } +} + +beforeEach(() => { + jest.clearAllMocks() + mockSave.mockImplementation((entity: any) => Promise.resolve(entity)) +}) + +describe('mcpServerService', () => { + describe('parseMcpConfig', () => { + it('returns null when mcpServerConfig is undefined', () => { + const chatflow = makeChatflow() + expect(mcpServerService.parseMcpConfig(chatflow as any)).toBeNull() + }) + + it('returns null when mcpServerConfig is empty string', () => { + const chatflow = makeChatflow({ mcpServerConfig: '' }) + expect(mcpServerService.parseMcpConfig(chatflow as any)).toBeNull() + }) + + it('parses valid JSON config', () => { + const config = makeConfig() + const chatflow = makeChatflow({ mcpServerConfig: JSON.stringify(config) }) + expect(mcpServerService.parseMcpConfig(chatflow as any)).toEqual(config) + }) + + it('returns null for invalid JSON', () => { + const chatflow = makeChatflow({ mcpServerConfig: '{bad json' }) + expect(mcpServerService.parseMcpConfig(chatflow as any)).toBeNull() + }) + }) + + describe('getMcpServerConfig', () => { + it('returns config when chatflow exists and has config', async () => { + const config = makeConfig() + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(config) })) + + const result = await mcpServerService.getMcpServerConfig('chatflow-1', 'ws-1') + expect(result).toEqual(config) + }) + + it('returns disabled config when chatflow has no config', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + const result = await mcpServerService.getMcpServerConfig('chatflow-1', 'ws-1') + expect(result).toEqual({ enabled: false, token: '', description: '', toolName: '' }) + }) + + it('throws NOT_FOUND when chatflow does not exist', async () => { + mockFindOne.mockResolvedValue(null) + await expect(mcpServerService.getMcpServerConfig('no-such', 'ws-1')).rejects.toThrow(InternalFlowiseError) + await expect(mcpServerService.getMcpServerConfig('no-such', 'ws-1')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + }) + + describe('createMcpServerConfig', () => { + it('creates a new config with generated token', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + + const result = await mcpServerService.createMcpServerConfig('chatflow-1', 'ws-1', { + description: 'My tool', + toolName: 'my_tool' + }) + + expect(result.enabled).toBe(true) + expect(result.token).toHaveLength(32) // 16 bytes hex = 32 chars + expect(result.description).toBe('My tool') + expect(result.toolName).toBe('my_tool') + expect(mockSave).toHaveBeenCalled() + }) + + it('returns existing config if already enabled', async () => { + const existing = makeConfig() + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(existing) })) + + const result = await mcpServerService.createMcpServerConfig('chatflow-1', 'ws-1', {} as any) + expect(result).toEqual(existing) + expect(mockSave).not.toHaveBeenCalled() + }) + + it('throws NOT_FOUND when chatflow does not exist', async () => { + mockFindOne.mockResolvedValue(null) + await expect(mcpServerService.createMcpServerConfig('no-such', 'ws-1', {} as any)).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + + it('rejects invalid toolName', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + await expect( + mcpServerService.createMcpServerConfig('chatflow-1', 'ws-1', { toolName: 'invalid name with spaces!', description: 'desc' }) + ).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST + }) + }) + + it('accepts valid toolName patterns', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + const result = await mcpServerService.createMcpServerConfig('chatflow-1', 'ws-1', { + toolName: 'valid-tool_name123', + description: 'A valid tool' + }) + expect(result.toolName).toBe('valid-tool_name123') + }) + + it('rejects missing toolName', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + await expect( + mcpServerService.createMcpServerConfig('chatflow-1', 'ws-1', { description: 'desc' } as any) + ).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST + }) + }) + + it('rejects missing description', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + await expect( + mcpServerService.createMcpServerConfig('chatflow-1', 'ws-1', { toolName: 'my_tool' } as any) + ).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST + }) + }) + }) + + describe('updateMcpServerConfig', () => { + it('updates description and toolName', async () => { + const existing = makeConfig() + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(existing) })) + + const result = await mcpServerService.updateMcpServerConfig('chatflow-1', 'ws-1', { + description: 'Updated desc', + toolName: 'new_name' + }) + + expect(result.description).toBe('Updated desc') + expect(result.toolName).toBe('new_name') + expect(mockSave).toHaveBeenCalled() + }) + + it('can disable config via enabled=false', async () => { + const existing = makeConfig() + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(existing) })) + + const result = await mcpServerService.updateMcpServerConfig('chatflow-1', 'ws-1', { enabled: false }) + expect(result.enabled).toBe(false) + }) + + it('throws NOT_FOUND when no existing config', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + await expect(mcpServerService.updateMcpServerConfig('chatflow-1', 'ws-1', {})).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + + it('rejects invalid toolName on update', async () => { + const existing = makeConfig() + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(existing) })) + + await expect(mcpServerService.updateMcpServerConfig('chatflow-1', 'ws-1', { toolName: 'a'.repeat(65) })).rejects.toMatchObject({ + statusCode: StatusCodes.BAD_REQUEST + }) + }) + }) + + describe('deleteMcpServerConfig', () => { + it('sets enabled=false (soft delete)', async () => { + const existing = makeConfig() + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(existing) })) + + await mcpServerService.deleteMcpServerConfig('chatflow-1', 'ws-1') + + expect(mockSave).toHaveBeenCalled() + const savedEntity = mockSave.mock.calls[0][0] + const savedConfig = JSON.parse(savedEntity.mcpServerConfig) + expect(savedConfig.enabled).toBe(false) + // Token should be preserved + expect(savedConfig.token).toBe(existing.token) + }) + + it('does nothing when no config exists', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + await mcpServerService.deleteMcpServerConfig('chatflow-1', 'ws-1') + expect(mockSave).not.toHaveBeenCalled() + }) + + it('throws NOT_FOUND when chatflow does not exist', async () => { + mockFindOne.mockResolvedValue(null) + await expect(mcpServerService.deleteMcpServerConfig('no-such', 'ws-1')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + }) + + describe('refreshMcpToken', () => { + it('generates a new token', async () => { + const existing = makeConfig({ token: 'old-token-value-1234567890ab' }) + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(existing) })) + + const result = await mcpServerService.refreshMcpToken('chatflow-1', 'ws-1') + + expect(result.token).not.toBe('old-token-value-1234567890ab') + expect(result.token).toHaveLength(32) + expect(result.enabled).toBe(true) + expect(mockSave).toHaveBeenCalled() + }) + + it('throws NOT_FOUND when no config exists', async () => { + mockFindOne.mockResolvedValue(makeChatflow()) + await expect(mcpServerService.refreshMcpToken('chatflow-1', 'ws-1')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + + it('throws NOT_FOUND when chatflow does not exist', async () => { + mockFindOne.mockResolvedValue(null) + await expect(mcpServerService.refreshMcpToken('no-such', 'ws-1')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + }) + + describe('getChatflowByIdAndVerifyToken', () => { + it('returns chatflow when token matches', async () => { + const token = 'abcdef1234567890abcdef1234567890' + const config = makeConfig({ token }) + const chatflow = makeChatflow({ mcpServerConfig: JSON.stringify(config) }) + mockFindOne.mockResolvedValue(chatflow) + + const result = await mcpServerService.getChatflowByIdAndVerifyToken('chatflow-1', token) + expect(result.id).toBe('chatflow-1') + }) + + it('throws UNAUTHORIZED when token does not match', async () => { + const config = makeConfig({ token: 'correct-token-00001234567890ab' }) + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(config) })) + + await expect( + mcpServerService.getChatflowByIdAndVerifyToken('chatflow-1', 'wrong-token-000001234567890ab') + ).rejects.toMatchObject({ + statusCode: StatusCodes.UNAUTHORIZED + }) + }) + + it('throws NOT_FOUND when chatflow does not exist', async () => { + mockFindOne.mockResolvedValue(null) + await expect(mcpServerService.getChatflowByIdAndVerifyToken('no-such', 'token')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + + it('throws NOT_FOUND when config is disabled', async () => { + const config = makeConfig({ enabled: false }) + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(config) })) + + await expect(mcpServerService.getChatflowByIdAndVerifyToken('chatflow-1', config.token)).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + + it('throws NOT_FOUND when config has no token', async () => { + const config = { enabled: true, token: '' } + mockFindOne.mockResolvedValue(makeChatflow({ mcpServerConfig: JSON.stringify(config) })) + + await expect(mcpServerService.getChatflowByIdAndVerifyToken('chatflow-1', 'some-token')).rejects.toMatchObject({ + statusCode: StatusCodes.NOT_FOUND + }) + }) + }) +}) diff --git a/packages/server/src/services/mcp-server/index.ts b/packages/server/src/services/mcp-server/index.ts new file mode 100644 index 00000000000..63399cb8619 --- /dev/null +++ b/packages/server/src/services/mcp-server/index.ts @@ -0,0 +1,267 @@ +import { StatusCodes } from 'http-status-codes' +import crypto from 'crypto' +import { z } from 'zod/v3' +import { ChatFlow } from '../../database/entities/ChatFlow' +import { InternalFlowiseError } from '../../errors/internalFlowiseError' +import { getErrorMessage } from '../../errors/utils' +import { getRunningExpressApp } from '../../utils/getRunningExpressApp' +import { IMcpServerConfig } from '../../Interface' + +const toolNameSchema = z + .string() + .min(1, 'toolName is required') + .max(64, 'toolName must be 64 characters or less') + .regex(/^[a-zA-Z0-9_-]+$/, 'toolName must contain only alphanumeric characters, underscores, and hyphens') + +const createConfigSchema = z.object({ + toolName: toolNameSchema, + description: z.string().min(1, 'description is required') +}) + +const updateConfigSchema = z.object({ + toolName: toolNameSchema.optional(), + description: z.string().min(1, 'description cannot be empty').optional(), + enabled: z.boolean().optional() +}) + +function validateWithZod(schema: z.ZodSchema, data: unknown): T { + const result = schema.safeParse(data) + if (!result.success) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, result.error.errors[0].message) + } + return result.data +} + +/** + * Generate a random 32-char hex token (128 bits of entropy) + */ +function generateToken(): string { + return crypto.randomBytes(16).toString('hex') +} + +/** + * Parse the mcpServerConfig JSON string from a ChatFlow entity + */ +function parseMcpConfig(chatflow: ChatFlow): IMcpServerConfig | null { + if (!chatflow.mcpServerConfig) return null + try { + return JSON.parse(chatflow.mcpServerConfig) as IMcpServerConfig + } catch { + return null + } +} + +/** + * Get MCP server config for a chatflow + */ +const getMcpServerConfig = async (chatflowId: string, workspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: chatflowId, workspaceId } + }) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) + } + const config = parseMcpConfig(chatflow) + return config || { enabled: false, token: '', description: '', toolName: '' } + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: mcpServerService.getMcpServerConfig - ${getErrorMessage(error)}` + ) + } +} + +/** + * Enable MCP server for a chatflow — generates a token and saves config + */ +const createMcpServerConfig = async ( + chatflowId: string, + workspaceId: string, + body: { description: string; toolName: string } +): Promise => { + try { + const appServer = getRunningExpressApp() + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: chatflowId, workspaceId } + }) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) + } + + // If already has an MCP config, return it + const existing = parseMcpConfig(chatflow) + if (existing && existing.enabled) { + return existing + } + + validateWithZod(createConfigSchema, body) + + const config: IMcpServerConfig = { + enabled: true, + token: generateToken(), + description: body.description, + toolName: body.toolName + } + + chatflow.mcpServerConfig = JSON.stringify(config) + await appServer.AppDataSource.getRepository(ChatFlow).save(chatflow) + + return config + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: mcpServerService.createMcpServerConfig - ${getErrorMessage(error)}` + ) + } +} + +/** + * Update MCP server config (description, toolName, enabled/disabled) + */ +const updateMcpServerConfig = async ( + chatflowId: string, + workspaceId: string, + body: { description?: string; toolName?: string; enabled?: boolean } +): Promise => { + try { + const appServer = getRunningExpressApp() + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: chatflowId, workspaceId } + }) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) + } + + const existing = parseMcpConfig(chatflow) + if (!existing) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `MCP server config not found for ID: ${chatflowId}`) + } + + validateWithZod(updateConfigSchema, body) + + if (body.description !== undefined) existing.description = body.description + if (body.toolName !== undefined) existing.toolName = body.toolName + if (body.enabled !== undefined) existing.enabled = body.enabled + + chatflow.mcpServerConfig = JSON.stringify(existing) + await appServer.AppDataSource.getRepository(ChatFlow).save(chatflow) + + return existing + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: mcpServerService.updateMcpServerConfig - ${getErrorMessage(error)}` + ) + } +} + +/** + * Disable (soft delete) MCP server config + */ +const deleteMcpServerConfig = async (chatflowId: string, workspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: chatflowId, workspaceId } + }) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) + } + + const existing = parseMcpConfig(chatflow) + if (!existing) return + + existing.enabled = false + chatflow.mcpServerConfig = JSON.stringify(existing) + await appServer.AppDataSource.getRepository(ChatFlow).save(chatflow) + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: mcpServerService.deleteMcpServerConfig - ${getErrorMessage(error)}` + ) + } +} + +/** + * Rotate (regenerate) the token + */ +const refreshMcpToken = async (chatflowId: string, workspaceId: string): Promise => { + try { + const appServer = getRunningExpressApp() + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: chatflowId, workspaceId } + }) + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`) + } + + const existing = parseMcpConfig(chatflow) + if (!existing) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `MCP server config not found for ID: ${chatflowId}`) + } + + existing.token = generateToken() + chatflow.mcpServerConfig = JSON.stringify(existing) + await appServer.AppDataSource.getRepository(ChatFlow).save(chatflow) + + return existing + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: mcpServerService.refreshMcpToken - ${getErrorMessage(error)}` + ) + } +} + +/** + * Look up a chatflow by ID and verify the MCP token (constant-time comparison). + */ +const getChatflowByIdAndVerifyToken = async (chatflowId: string, token: string): Promise => { + try { + const appServer = getRunningExpressApp() + const chatflow = await appServer.AppDataSource.getRepository(ChatFlow).findOne({ + where: { id: chatflowId } + }) + + if (!chatflow) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'MCP server not found') + } + + const config = parseMcpConfig(chatflow) + if (!config || !config.enabled || !config.token) { + throw new InternalFlowiseError(StatusCodes.NOT_FOUND, 'MCP server not found') + } + + // Constant-time comparison to prevent timing attacks + const storedBuffer = Buffer.from(config.token, 'utf8') + const providedBuffer = Buffer.from(token, 'utf8') + if (storedBuffer.length !== providedBuffer.length || !crypto.timingSafeEqual(storedBuffer, providedBuffer)) { + throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid token') + } + + return chatflow + } catch (error) { + if (error instanceof InternalFlowiseError) throw error + throw new InternalFlowiseError( + StatusCodes.INTERNAL_SERVER_ERROR, + `Error: mcpServerService.getChatflowByIdAndVerifyToken - ${getErrorMessage(error)}` + ) + } +} + +export default { + getMcpServerConfig, + createMcpServerConfig, + updateMcpServerConfig, + deleteMcpServerConfig, + refreshMcpToken, + getChatflowByIdAndVerifyToken, + parseMcpConfig +} diff --git a/packages/server/src/services/nodes/nodes.test.ts b/packages/server/src/services/nodes/nodes.test.ts index 4af0400e771..6901d3629b7 100644 --- a/packages/server/src/services/nodes/nodes.test.ts +++ b/packages/server/src/services/nodes/nodes.test.ts @@ -178,6 +178,42 @@ describe('filterNodeByClient', () => { }) }) + describe('Start node form input filtering', () => { + const startNode = makeNode({ + inputs: [ + { + label: 'Input Type', + name: 'startInputType', + type: 'options', + options: [ + { label: 'Chat Input', name: 'chatInput' }, + { label: 'Form Input', name: 'formInput', client: ['agentflowv2'] } + ], + default: 'chatInput' + }, + { label: 'Form Title', name: 'formTitle', type: 'string' }, + { label: 'Form Description', name: 'formDescription', type: 'string' }, + { label: 'Form Input Types', name: 'formInputTypes', type: 'array' }, + { label: 'Ephemeral Memory', name: 'startEphemeralMemory', type: 'boolean' }, + { label: 'Flow State', name: 'startState', type: 'array' } + ] + }) + + it('removes formInput option for agentflowsdk', () => { + const result = filterNodeByClient(startNode, 'agentflowsdk') + const optionNames = result.inputs![0].options!.map((o: any) => o.name) + expect(optionNames).toContain('chatInput') + expect(optionNames).not.toContain('formInput') + }) + + it('keeps formInput option for agentflowv2', () => { + const result = filterNodeByClient(startNode, 'agentflowv2') + const optionNames = result.inputs![0].options!.map((o: any) => o.name) + expect(optionNames).toContain('chatInput') + expect(optionNames).toContain('formInput') + }) + }) + describe('immutability', () => { it('does not mutate the original node', () => { const node = makeNode({ diff --git a/packages/server/src/utils/SSEStreamer.ts b/packages/server/src/utils/SSEStreamer.ts index 5c442393b8a..ef174fc59ab 100644 --- a/packages/server/src/utils/SSEStreamer.ts +++ b/packages/server/src/utils/SSEStreamer.ts @@ -231,6 +231,9 @@ export class SSEStreamer implements IServerSideEventStreamer { metadataJson['flowVariables'] = typeof apiResponse.flowVariables === 'string' ? JSON.parse(apiResponse.flowVariables) : apiResponse.flowVariables } + if (apiResponse.action) { + metadataJson['action'] = typeof apiResponse.action === 'string' ? JSON.parse(apiResponse.action) : apiResponse.action + } if (Object.keys(metadataJson).length > 0) { this.streamCustomEvent(chatId, 'metadata', metadataJson) } diff --git a/packages/server/src/utils/XSS.test.ts b/packages/server/src/utils/XSS.test.ts new file mode 100644 index 00000000000..2b004f1cd5e --- /dev/null +++ b/packages/server/src/utils/XSS.test.ts @@ -0,0 +1,134 @@ +// At the top of XSS.test.ts, before importing getAllowedIframeOrigins +jest.mock('./domainValidation', () => ({ + extractChatflowId: jest.fn(), + isPublicChatflowRequest: jest.fn(), + isTTSGenerateRequest: jest.fn(), + validateChatflowDomain: jest.fn() +})) + +import { getAllowedIframeOrigins } from './XSS' + +describe('getAllowedIframeOrigins', () => { + const originalEnv = process.env.IFRAME_ORIGINS + + afterEach(() => { + // Restore original environment variable after each test + if (originalEnv !== undefined) { + process.env.IFRAME_ORIGINS = originalEnv + } else { + delete process.env.IFRAME_ORIGINS + } + }) + + describe('default behavior', () => { + it("should return 'self' when IFRAME_ORIGINS is not set", () => { + delete process.env.IFRAME_ORIGINS + expect(getAllowedIframeOrigins()).toBe("'self'") + }) + + it("should return 'self' when IFRAME_ORIGINS is empty string", () => { + process.env.IFRAME_ORIGINS = '' + expect(getAllowedIframeOrigins()).toBe("'self'") + }) + + it("should return 'self' when IFRAME_ORIGINS is only whitespace", () => { + process.env.IFRAME_ORIGINS = ' ' + expect(getAllowedIframeOrigins()).toBe("'self'") + }) + }) + + describe('CSP special values', () => { + it("should handle 'self' value", () => { + process.env.IFRAME_ORIGINS = "'self'" + expect(getAllowedIframeOrigins()).toBe("'self'") + }) + + it("should handle 'none' value", () => { + process.env.IFRAME_ORIGINS = "'none'" + expect(getAllowedIframeOrigins()).toBe("'none'") + }) + + it('should handle wildcard * value', () => { + process.env.IFRAME_ORIGINS = '*' + expect(getAllowedIframeOrigins()).toBe('*') + }) + }) + + describe('single domain', () => { + it('should handle a single FQDN', () => { + process.env.IFRAME_ORIGINS = 'https://example.com' + expect(getAllowedIframeOrigins()).toBe('https://example.com') + }) + + it('should trim whitespace from single domain', () => { + process.env.IFRAME_ORIGINS = ' https://example.com ' + expect(getAllowedIframeOrigins()).toBe('https://example.com') + }) + }) + + describe('multiple domains', () => { + it('should convert comma-separated domains to space-separated', () => { + process.env.IFRAME_ORIGINS = 'https://domain1.com,https://domain2.com' + expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com') + }) + + it('should handle multiple domains with spaces', () => { + process.env.IFRAME_ORIGINS = 'https://domain1.com, https://domain2.com, https://domain3.com' + expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com https://domain3.com') + }) + + it('should trim individual domains in comma-separated list', () => { + process.env.IFRAME_ORIGINS = ' https://app.com , https://admin.com ' + expect(getAllowedIframeOrigins()).toBe('https://app.com https://admin.com') + }) + }) + + describe('edge cases', () => { + it('should handle domains with ports', () => { + process.env.IFRAME_ORIGINS = 'https://localhost:3000,https://localhost:4000' + expect(getAllowedIframeOrigins()).toBe('https://localhost:3000 https://localhost:4000') + }) + + it('should handle domains with paths (though not typical for CSP)', () => { + process.env.IFRAME_ORIGINS = 'https://example.com/path1,https://example.com/path2' + expect(getAllowedIframeOrigins()).toBe('https://example.com/path1 https://example.com/path2') + }) + + it('should handle mixed protocols', () => { + process.env.IFRAME_ORIGINS = 'http://example.com,https://secure.com' + expect(getAllowedIframeOrigins()).toBe('http://example.com https://secure.com') + }) + + it('should handle trailing comma', () => { + process.env.IFRAME_ORIGINS = 'https://example.com,' + expect(getAllowedIframeOrigins()).toBe('https://example.com ') + }) + + it('should handle leading comma', () => { + process.env.IFRAME_ORIGINS = ',https://example.com' + expect(getAllowedIframeOrigins()).toBe(' https://example.com') + }) + + it('should handle multiple consecutive commas', () => { + process.env.IFRAME_ORIGINS = 'https://domain1.com,,https://domain2.com' + expect(getAllowedIframeOrigins()).toBe('https://domain1.com https://domain2.com') + }) + }) + + describe('real-world scenarios', () => { + it('should handle typical production configuration', () => { + process.env.IFRAME_ORIGINS = 'https://app.example.com,https://admin.example.com,https://dashboard.example.com' + expect(getAllowedIframeOrigins()).toBe('https://app.example.com https://admin.example.com https://dashboard.example.com') + }) + + it('should handle development configuration with localhost', () => { + process.env.IFRAME_ORIGINS = 'http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000' + expect(getAllowedIframeOrigins()).toBe('http://localhost:3000 http://localhost:3001 http://127.0.0.1:3000') + }) + + it("should handle mix of 'self' and domains", () => { + process.env.IFRAME_ORIGINS = "'self',https://trusted.com" + expect(getAllowedIframeOrigins()).toBe("'self' https://trusted.com") + }) + }) +}) diff --git a/packages/server/src/utils/XSS.ts b/packages/server/src/utils/XSS.ts index 02f2e0e2978..c6d28ee5b79 100644 --- a/packages/server/src/utils/XSS.ts +++ b/packages/server/src/utils/XSS.ts @@ -1,6 +1,6 @@ -import { Request, Response, NextFunction } from 'express' +import { NextFunction, Request, Response } from 'express' import sanitizeHtml from 'sanitize-html' -import { extractChatflowId, validateChatflowDomain, isPublicChatflowRequest, isTTSGenerateRequest } from './domainValidation' +import { extractChatflowId, isPublicChatflowRequest, isTTSGenerateRequest, validateChatflowDomain } from './domainValidation' export function sanitizeMiddleware(req: Request, res: Response, next: NextFunction): void { // decoding is necessary as the url is encoded by the browser @@ -87,8 +87,31 @@ export function getCorsOptions(): any { } } +/** + * Retrieves and normalizes allowed iframe embedding origins for CSP frame-ancestors directive. + * + * Reads `IFRAME_ORIGINS` environment variable (comma-separated FQDNs) and converts it to + * space-separated format required by Content Security Policy specification. + * + * Input format: + * - Comma-separated: `https://domain1.com,https://domain2.com` + * - Special values: `'self'`, `'none'`, or `*` + * - Default: `'self'` (same-origin only) + * + * Output examples: + * - `https://app.com,https://admin.com` → `https://app.com https://admin.com` + * - `'self'` → `'self'` + * - `*` → `*` + * + * @returns Space-separated string for CSP frame-ancestors directive + */ export function getAllowedIframeOrigins(): string { // Expects FQDN separated by commas, otherwise nothing or * for all. // Also CSP allowed values: self or none - return process.env.IFRAME_ORIGINS ?? '*' + const origins = (process.env.IFRAME_ORIGINS?.trim() || undefined) ?? "'self'" + // Convert CSV to space-separated for CSP frame-ancestors directive + return origins + .split(',') + .map((s) => s.trim()) + .join(' ') } diff --git a/packages/server/src/utils/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts index 5ad744df8ce..6d1d937377c 100644 --- a/packages/server/src/utils/buildAgentflow.ts +++ b/packages/server/src/utils/buildAgentflow.ts @@ -1163,7 +1163,7 @@ const executeNode = async ({ !isRecursive && (!graph[nodeId] || graph[nodeId].length === 0 || (!humanInput && reactFlowNode.data.name === 'humanInputAgentflow')) - if (incomingInput.question && incomingInput.form) { + if (incomingInput.question && isObjectNotEmpty(incomingInput.form)) { throw new Error('Question and form cannot be provided at the same time') } @@ -1171,7 +1171,7 @@ const executeNode = async ({ if (incomingInput.question) { // Prepare final question with uploaded content if any finalInput = uploadedFilesContent ? `${uploadedFilesContent}\n\n${incomingInput.question}` : incomingInput.question - } else if (incomingInput.form) { + } else if (isObjectNotEmpty(incomingInput.form)) { finalInput = Object.entries(incomingInput.form || {}) .map(([key, value]) => `${key}: ${value}`) .join('\n') @@ -1512,6 +1512,7 @@ export const executeAgentFlow = async ({ parentExecutionId, iterationContext, isTool = false, + chatType, orgId, workspaceId, subscriptionId, @@ -2261,7 +2262,7 @@ export const executeAgentFlow = async ({ role: 'userMessage', content: finalUserInput, chatflowid, - chatType: evaluationRunId ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL, + chatType: chatType || (evaluationRunId ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL), chatId, sessionId, createdDate: userMessageDateTime, @@ -2276,7 +2277,7 @@ export const executeAgentFlow = async ({ role: 'apiMessage', content: content, chatflowid, - chatType: evaluationRunId ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL, + chatType: chatType || (evaluationRunId ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL), chatId, sessionId, executionId: newExecution.id @@ -2331,6 +2332,7 @@ export const executeAgentFlow = async ({ result.followUpPrompts = JSON.stringify(apiMessage.followUpPrompts) result.executionId = newExecution.id result.agentFlowExecutedData = agentFlowExecutedData + if (apiMessage.action) result.action = JSON.parse(apiMessage.action) if (sessionId) result.sessionId = sessionId diff --git a/packages/server/src/utils/buildChatflow.ts b/packages/server/src/utils/buildChatflow.ts index c54c009c43a..a26ab5c5154 100644 --- a/packages/server/src/utils/buildChatflow.ts +++ b/packages/server/src/utils/buildChatflow.ts @@ -315,6 +315,7 @@ export const executeFlow = async ({ files, signal, isTool, + chatType, orgId, workspaceId, subscriptionId, @@ -477,7 +478,7 @@ export const executeFlow = async ({ } } - const isAgentFlowV2 = chatflow.type === 'AGENTFLOW' + const isAgentFlowV2 = chatflow.type === 'AGENTFLOW' || chatflow.type === 'AGENT' if (isAgentFlowV2) { return executeAgentFlow({ componentNodes, @@ -492,6 +493,7 @@ export const executeFlow = async ({ sseStreamer, baseURL, isInternal, + chatType, uploadedFilesContent, fileUploads, signal, @@ -632,7 +634,7 @@ export const executeFlow = async ({ role: 'userMessage', content: incomingInput.question, chatflowid: agentflow.id, - chatType: isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL, + chatType: chatType || (isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL), chatId, memoryType, sessionId, @@ -647,7 +649,7 @@ export const executeFlow = async ({ role: 'apiMessage', content: finalResult, chatflowid: agentflow.id, - chatType: isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL, + chatType: chatType || (isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL), chatId, memoryType, sessionId @@ -797,7 +799,7 @@ export const executeFlow = async ({ role: 'userMessage', content: question, chatflowid, - chatType: isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL, + chatType: chatType || (isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL), chatId, memoryType, sessionId, @@ -862,7 +864,7 @@ export const executeFlow = async ({ role: 'apiMessage', content: resultText, chatflowid, - chatType: isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL, + chatType: chatType || (isEvaluation ? ChatType.EVALUATION : isInternal ? ChatType.INTERNAL : ChatType.EXTERNAL), chatId, memoryType, sessionId @@ -871,6 +873,7 @@ export const executeFlow = async ({ if (result?.usedTools) apiMessage.usedTools = JSON.stringify(result.usedTools) if (result?.fileAnnotations) apiMessage.fileAnnotations = JSON.stringify(result.fileAnnotations) if (result?.artifacts) apiMessage.artifacts = JSON.stringify(result.artifacts) + if (result?.action) apiMessage.action = typeof result.action === 'string' ? result.action : JSON.stringify(result.action) if (chatflow.followUpPrompts) { const followUpPromptsConfig = JSON.parse(chatflow.followUpPrompts) const followUpPrompts = await generateFollowUpPrompts(followUpPromptsConfig, apiMessage.content, { @@ -985,7 +988,7 @@ const checkIfStreamValid = async ( * @param {Request} req * @param {boolean} isInternal */ -export const utilBuildChatflow = async (req: Request, isInternal: boolean = false): Promise => { +export const utilBuildChatflow = async (req: Request, isInternal: boolean = false, chatType?: ChatType): Promise => { const appServer = getRunningExpressApp() const chatflowid = req.params.id @@ -1071,6 +1074,7 @@ export const utilBuildChatflow = async (req: Request, isInternal: boolean = fals cachePool: appServer.cachePool, componentNodes: appServer.nodesPool.componentNodes, isTool, // used to disable streaming if incoming request its from ChatflowTool + chatType, usageCacheManager: appServer.usageCacheManager, orgId, workspaceId, diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts index f5ff2f52100..a2de53c5f9e 100644 --- a/packages/server/src/utils/constants.ts +++ b/packages/server/src/utils/constants.ts @@ -39,6 +39,7 @@ export const WHITELIST_URLS = [ '/api/v1/user/test', '/api/v1/oauth2-credential/callback', '/api/v1/oauth2-credential/refresh', + '/api/v1/mcp/', '/api/v1/text-to-speech/generate', '/api/v1/text-to-speech/abort', AzureSSO.LOGIN_URI, diff --git a/packages/server/src/utils/mockRequest.test.ts b/packages/server/src/utils/mockRequest.test.ts new file mode 100644 index 00000000000..0e6d6447dd3 --- /dev/null +++ b/packages/server/src/utils/mockRequest.test.ts @@ -0,0 +1,77 @@ +import { createMockRequest } from './mockRequest' +import { Request } from 'express' + +describe('createMockRequest', () => { + it('sets params.id to chatflowId', () => { + const req = createMockRequest({ chatflowId: 'flow-123' }) + expect(req.params.id).toBe('flow-123') + }) + + it('sets default body with streaming=true and empty question', () => { + const req = createMockRequest({ chatflowId: 'flow-123' }) + expect(req.body).toEqual({ streaming: true, question: '' }) + }) + + it('merges custom body fields with defaults', () => { + const req = createMockRequest({ + chatflowId: 'flow-123', + body: { question: 'Hello', form: { key: 'value' } } + }) + expect(req.body.streaming).toBe(true) + expect(req.body.question).toBe('Hello') + expect(req.body.form).toEqual({ key: 'value' }) + }) + + it('custom body overrides defaults', () => { + const req = createMockRequest({ + chatflowId: 'flow-123', + body: { streaming: false } + }) + expect(req.body.streaming).toBe(false) + }) + + it('defaults protocol to http when no sourceRequest', () => { + const req = createMockRequest({ chatflowId: 'flow-123' }) + expect(req.protocol).toBe('http') + }) + + it('defaults files to empty array', () => { + const req = createMockRequest({ chatflowId: 'flow-123' }) + expect(req.files).toEqual([]) + }) + + it('get() returns host header from default headers', () => { + const req = createMockRequest({ chatflowId: 'flow-123' }) + expect(req.get('host')).toBe('localhost:3000') + }) + + it('get() returns undefined for missing headers', () => { + const req = createMockRequest({ chatflowId: 'flow-123' }) + expect(req.get('x-forwarded-proto')).toBeUndefined() + }) + + it('inherits protocol from sourceRequest', () => { + const sourceReq = { protocol: 'https', headers: {}, get: jest.fn() } as unknown as Request + const req = createMockRequest({ chatflowId: 'flow-123', sourceRequest: sourceReq }) + expect(req.protocol).toBe('https') + }) + + it('delegates get() to sourceRequest when provided', () => { + const getMock = jest.fn().mockReturnValue('example.com') + const sourceReq = { protocol: 'https', headers: { host: 'example.com' }, get: getMock } as unknown as Request + const req = createMockRequest({ chatflowId: 'flow-123', sourceRequest: sourceReq }) + expect(req.get('host')).toBe('example.com') + expect(getMock).toHaveBeenCalledWith('host') + }) + + it('copies headers from sourceRequest', () => { + const sourceReq = { + protocol: 'https', + headers: { host: 'example.com', authorization: 'Bearer token' }, + get: jest.fn() + } as unknown as Request + const req = createMockRequest({ chatflowId: 'flow-123', sourceRequest: sourceReq }) + expect(req.headers).toHaveProperty('host', 'example.com') + expect(req.headers).toHaveProperty('authorization', 'Bearer token') + }) +}) diff --git a/packages/server/src/utils/mockRequest.ts b/packages/server/src/utils/mockRequest.ts new file mode 100644 index 00000000000..44993d62045 --- /dev/null +++ b/packages/server/src/utils/mockRequest.ts @@ -0,0 +1,48 @@ +import { Request } from 'express' + +export interface MockRequestOptions { + /** The chatflow ID — sets req.params.id */ + chatflowId: string + /** The request body — merged with defaults */ + body?: Record + /** The original incoming request to inherit host/protocol/headers from */ + sourceRequest?: Request + /** Uploaded files (default: []) */ + files?: Express.Multer.File[] +} + +/** + * Create a typed mock Express Request for use with utilBuildChatflow(). + * + * This factory produces a minimal Request-compatible object that satisfies + * all properties accessed by utilBuildChatflow(): + * - req.params.id + * - req.body (question, streaming, form, etc.) + * - req.get(header) (host, x-forwarded-proto, flowise-tool) + * - req.protocol + * - req.headers + * - req.files + */ +export function createMockRequest(options: MockRequestOptions): Request { + const { chatflowId, body = {}, sourceRequest, files = [] } = options + + const headers: Record = sourceRequest ? { ...sourceRequest.headers } : { host: 'localhost:3000' } + + return { + params: { id: chatflowId }, + body: { + streaming: true, + question: '', + ...body + }, + get: (header: string) => { + if (sourceRequest) return sourceRequest.get(header) + const lower = header.toLowerCase() + const val = headers[lower] + return typeof val === 'string' ? val : undefined + }, + protocol: sourceRequest?.protocol ?? 'http', + headers, + files + } as unknown as Request +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 59460cd4ba9..249dbded054 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "flowise-ui", - "version": "3.1.1", + "version": "3.1.2", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://flowiseai.com", "author": { @@ -38,6 +38,7 @@ "@uiw/codemirror-theme-vscode": "^4.21.21", "@uiw/react-codemirror": "^4.21.21", "axios": "1.12.0", + "boring-avatars": "^2.0.4", "clsx": "^1.1.1", "dompurify": "^3.2.6", "dotenv": "^16.0.0", @@ -84,7 +85,7 @@ "build": "vite build", "test": "jest", "clean": "rimraf build", - "nuke": "rimraf build node_modules .turbo" + "nuke": "pnpm clean && rimraf node_modules .turbo" }, "babel": { "presets": [ diff --git a/packages/ui/src/api/mcpserver.js b/packages/ui/src/api/mcpserver.js new file mode 100644 index 00000000000..f84780aa429 --- /dev/null +++ b/packages/ui/src/api/mcpserver.js @@ -0,0 +1,19 @@ +import client from './client' + +const getMcpServerConfig = (id) => client.get(`/mcp-server/${id}`) + +const createMcpServerConfig = (id, body) => client.post(`/mcp-server/${id}`, body) + +const updateMcpServerConfig = (id, body) => client.put(`/mcp-server/${id}`, body) + +const deleteMcpServerConfig = (id) => client.delete(`/mcp-server/${id}`) + +const refreshMcpToken = (id) => client.post(`/mcp-server/${id}/refresh`) + +export default { + getMcpServerConfig, + createMcpServerConfig, + updateMcpServerConfig, + deleteMcpServerConfig, + refreshMcpToken +} diff --git a/packages/ui/src/api/user.js b/packages/ui/src/api/user.js index 86165ec9ca7..b47ff4851f5 100644 --- a/packages/ui/src/api/user.js +++ b/packages/ui/src/api/user.js @@ -25,6 +25,7 @@ const getPlanProration = (subscriptionId, newPlanId) => const updateSubscriptionPlan = (subscriptionId, newPlanId, prorationDate) => client.post(`/organization/update-subscription-plan`, { subscriptionId, newPlanId, prorationDate }) const getCurrentUsage = () => client.get(`/organization/get-current-usage`) +const getOrganizationById = (id) => client.get(`/organization?id=${id}`) // workspace users const getAllUsersByWorkspaceId = (workspaceId) => client.get(`/workspaceuser?workspaceId=${workspaceId}`) @@ -55,5 +56,6 @@ export default { getPlanProration, updateSubscriptionPlan, getCurrentUsage, - deleteOrganizationUser + deleteOrganizationUser, + getOrganizationById } diff --git a/packages/ui/src/assets/images/langchain.png b/packages/ui/src/assets/images/langchain.png index 587dd314071..d8ee337ff1f 100644 Binary files a/packages/ui/src/assets/images/langchain.png and b/packages/ui/src/assets/images/langchain.png differ diff --git a/packages/ui/src/assets/scss/_themes-vars.module.scss b/packages/ui/src/assets/scss/_themes-vars.module.scss index 7235b84474a..9ef55509a6b 100644 --- a/packages/ui/src/assets/scss/_themes-vars.module.scss +++ b/packages/ui/src/assets/scss/_themes-vars.module.scss @@ -50,6 +50,7 @@ $grey400: #c4c4c4; $grey500: #9e9e9e; $grey600: #757575; $grey700: #616161; +$grey800: #424242; $grey900: #212121; // transparent @@ -139,6 +140,7 @@ $darkTextSecondary: #8492c4; grey500: $grey500; grey600: $grey600; grey700: $grey700; + grey800: $grey800; grey900: $grey900; // ==============================|| DARK THEME VARIANTS ||============================== // diff --git a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.jsx b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.jsx index 054f409c9e7..3817ab1b1d0 100644 --- a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.jsx +++ b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavGroup/index.jsx @@ -1,8 +1,9 @@ import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' // material-ui import { useTheme } from '@mui/material/styles' -import { Divider, List, Typography } from '@mui/material' +import { Divider, List, Typography, useMediaQuery } from '@mui/material' // project imports import NavItem from '../NavItem' @@ -15,6 +16,9 @@ import { Available } from '@/ui-component/rbac/available' const NavGroup = ({ item }) => { const theme = useTheme() const { hasPermission, hasDisplay } = useAuth() + const drawerOpened = useSelector((state) => state.customization.opened) + const matchUpMd = useMediaQuery(theme.breakpoints.up('md')) + const collapsed = matchUpMd && !drawerOpened const listItems = (menu, level = 1) => { // Filter based on display and permission @@ -72,6 +76,7 @@ const NavGroup = ({ item }) => { <> {item.title} @@ -83,7 +88,7 @@ const NavGroup = ({ item }) => { ) } - sx={{ p: '16px', py: 2, display: 'flex', flexDirection: 'column', gap: 1 }} + sx={{ px: collapsed ? '8px' : '16px', py: 2, display: 'flex', flexDirection: 'column', gap: 1 }} > {renderPrimaryItems().map((menu) => listItems(menu))} @@ -96,11 +101,18 @@ const NavGroup = ({ item }) => { - {group.title} - + !collapsed && ( + + {group.title} + + ) } - sx={{ p: '16px', py: 2, display: 'flex', flexDirection: 'column', gap: 1 }} + sx={{ px: collapsed ? '8px' : '16px', py: 2, display: 'flex', flexDirection: 'column', gap: 1 }} > {group.children.map((menu) => listItems(menu))} diff --git a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.jsx b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.jsx index 10445554bd0..3ca99c290e5 100644 --- a/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.jsx +++ b/packages/ui/src/layout/MainLayout/Sidebar/MenuList/NavItem/index.jsx @@ -1,11 +1,11 @@ import PropTypes from 'prop-types' import { forwardRef, useEffect } from 'react' -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' // material-ui import { useTheme } from '@mui/material/styles' -import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, useMediaQuery } from '@mui/material' +import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Tooltip, Typography, useMediaQuery } from '@mui/material' // project imports import { MENU_OPEN, SET_MENU } from '@/store/actions' @@ -19,6 +19,7 @@ import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' const NavItem = ({ item, level, navType, onClick, onUploadFile }) => { const theme = useTheme() const dispatch = useDispatch() + const location = useLocation() const customization = useSelector((state) => state.customization) const matchesSM = useMediaQuery(theme.breakpoints.down('lg')) @@ -77,60 +78,79 @@ const NavItem = ({ item, level, navType, onClick, onUploadFile }) => { } } - // active menu item on page load + // active menu item on page load and route change useEffect(() => { if (navType === 'MENU') { - const currentIndex = document.location.pathname + const currentIndex = location.pathname .toString() .split('/') .findIndex((id) => id === item.id) if (currentIndex > -1) { dispatch({ type: MENU_OPEN, id: item.id }) } - if (!document.location.pathname.toString().split('/')[1]) { - itemHandler('chatflows') + if (!location.pathname.toString().split('/')[1]) { + itemHandler('agents') } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [navType]) + }, [navType, location.pathname]) - return ( + const drawerOpened = customization.opened + const matchUpMd = useMediaQuery(theme.breakpoints.up('md')) + const collapsed = matchUpMd && !drawerOpened + + const button = ( 1 ? 'transparent !important' : 'inherit', py: level > 1 ? 1 : 1.25, - pl: `${level * 24}px` + ...(collapsed ? { pl: 0, pr: 0, justifyContent: 'center' } : { pl: `${level * 24}px`, justifyContent: 'flex-start' }) }} selected={customization.isOpen.findIndex((id) => id === item.id) > -1} onClick={() => itemHandler(item.id)} > {item.id === 'loadChatflow' && handleFileUpload(e)} />} - {itemIcon} - id === item.id) > -1 ? 'h5' : 'body1'} - color='inherit' - sx={{ my: 0.5 }} - > - {item.title} - - } - secondary={ - item.caption && ( - - {item.caption} + + {itemIcon} + + {!collapsed && ( + id === item.id) > -1 ? 'h5' : 'body1'} + color='inherit' + sx={{ my: 0.5 }} + > + {item.title} - ) - } - sx={{ my: 'auto' }} - /> - {item.chip && ( + } + secondary={ + item.caption && ( + + {item.caption} + + ) + } + sx={{ my: 'auto' }} + /> + )} + {!collapsed && item.chip && ( { avatar={item.chip.avatar && {item.chip.avatar}} /> )} - {item.isBeta && ( + {!collapsed && item.isBeta && ( { )} ) + + return collapsed ? ( + + {button} + + ) : ( + button + ) } NavItem.propTypes = { diff --git a/packages/ui/src/layout/MainLayout/Sidebar/index.jsx b/packages/ui/src/layout/MainLayout/Sidebar/index.jsx index b05d6669ee8..427b2107f68 100644 --- a/packages/ui/src/layout/MainLayout/Sidebar/index.jsx +++ b/packages/ui/src/layout/MainLayout/Sidebar/index.jsx @@ -15,7 +15,7 @@ import LogoSection from '../LogoSection' import CloudMenuList from '@/layout/MainLayout/Sidebar/CloudMenuList' // store -import { drawerWidth, headerHeight } from '@/store/constant' +import { drawerWidth, miniDrawerWidth, headerHeight } from '@/store/constant' // ==============================|| SIDEBAR DRAWER ||============================== // @@ -60,32 +60,37 @@ const Sidebar = ({ drawerOpen, drawerToggle, window }) => { const container = window !== undefined ? () => window.document.body : undefined + const desktopWidth = drawerOpen ? drawerWidth : miniDrawerWidth + return ( t.transitions.create('width', { duration: t.transitions.duration.shortest }) }} aria-label='mailbox folders' > {isAuthenticated && ( t.transitions.create('width', { duration: t.transitions.duration.shortest }) } }} ModalProps={{ keepMounted: true }} diff --git a/packages/ui/src/layout/MainLayout/ViewHeader.jsx b/packages/ui/src/layout/MainLayout/ViewHeader.jsx index 2be32d8a5af..6654a843038 100644 --- a/packages/ui/src/layout/MainLayout/ViewHeader.jsx +++ b/packages/ui/src/layout/MainLayout/ViewHeader.jsx @@ -54,7 +54,7 @@ const ViewHeader = ({ {title} {description && ( prop !== 'open' })(({ }), marginRight: 0, [theme.breakpoints.up('md')]: { - marginLeft: -drawerWidth, - width: `calc(100% - ${drawerWidth}px)` + marginLeft: 0, + width: `calc(100% - ${miniDrawerWidth}px)` }, [theme.breakpoints.down('md')]: { marginLeft: '20px', diff --git a/packages/ui/src/menu-items/agentsettings.js b/packages/ui/src/menu-items/agentsettings.js index 7e7eb8dae48..a58067dd7c1 100644 --- a/packages/ui/src/menu-items/agentsettings.js +++ b/packages/ui/src/menu-items/agentsettings.js @@ -26,7 +26,7 @@ const icons = { // ==============================|| SETTINGS MENU ITEMS ||============================== // -const agent_settings = { +const agentSettings = { id: 'settings', title: '', type: 'group', @@ -46,12 +46,12 @@ const agent_settings = { icon: icons.IconUsers }, { - id: 'chatflowConfiguration', + id: 'agentConfiguration', title: 'Configuration', type: 'item', url: '', icon: icons.IconAdjustmentsHorizontal, - permission: 'agentflows:config' + permission: 'agents:update' }, { id: 'saveAsTemplate', @@ -62,38 +62,38 @@ const agent_settings = { permission: 'templates:flowexport' }, { - id: 'duplicateChatflow', - title: 'Duplicate Agents', + id: 'duplicateAgent', + title: 'Duplicate Agent', type: 'item', url: '', icon: icons.IconCopy, - permission: 'agentflows:duplicate' + permission: 'agents:create' }, { - id: 'loadChatflow', - title: 'Load Agents', + id: 'loadAgent', + title: 'Load Agent', type: 'item', url: '', icon: icons.IconFileUpload, - permission: 'agentflows:import' + permission: 'agents:create' }, { - id: 'exportChatflow', - title: 'Export Agents', + id: 'exportAgent', + title: 'Export Agent', type: 'item', url: '', icon: icons.IconFileExport, - permission: 'agentflows:export' + permission: 'agents:update' }, { - id: 'deleteChatflow', - title: 'Delete Agents', + id: 'deleteAgent', + title: 'Delete Agent', type: 'item', url: '', icon: icons.IconTrash, - permission: 'agentflows:delete' + permission: 'agents:delete' } ] } -export default agent_settings +export default agentSettings diff --git a/packages/ui/src/menu-items/customassistant.js b/packages/ui/src/menu-items/customassistant.js deleted file mode 100644 index dba00fbd4ba..00000000000 --- a/packages/ui/src/menu-items/customassistant.js +++ /dev/null @@ -1,52 +0,0 @@ -// assets -import { IconTrash, IconMessage, IconAdjustmentsHorizontal, IconUsers } from '@tabler/icons-react' - -// constant -const icons = { - IconTrash, - IconMessage, - IconAdjustmentsHorizontal, - IconUsers -} - -// ==============================|| SETTINGS MENU ITEMS ||============================== // - -const customAssistantSettings = { - id: 'settings', - title: '', - type: 'group', - children: [ - { - id: 'viewMessages', - title: 'View Messages', - type: 'item', - url: '', - icon: icons.IconMessage - }, - { - id: 'viewLeads', - title: 'View Leads', - type: 'item', - url: '', - icon: icons.IconUsers - }, - { - id: 'chatflowConfiguration', - title: 'Configuration', - type: 'item', - url: '', - icon: icons.IconAdjustmentsHorizontal, - permission: 'assistants:update' - }, - { - id: 'deleteAssistant', - title: 'Delete Assistant', - type: 'item', - url: '', - icon: icons.IconTrash, - permission: 'assistants:delete' - } - ] -} - -export default customAssistantSettings diff --git a/packages/ui/src/menu-items/dashboard.js b/packages/ui/src/menu-items/dashboard.js index a320d0ca3f9..b493359488f 100644 --- a/packages/ui/src/menu-items/dashboard.js +++ b/packages/ui/src/menu-items/dashboard.js @@ -8,6 +8,7 @@ import { IconTool, IconLock, IconRobot, + IconRobotFace, IconSettings, IconVariable, IconFiles, @@ -36,6 +37,7 @@ const icons = { IconTool, IconLock, IconRobot, + IconRobotFace, IconSettings, IconVariable, IconFiles, @@ -67,13 +69,13 @@ const dashboard = { type: 'group', children: [ { - id: 'chatflows', - title: 'Chatflows', + id: 'agents', + title: 'Agents', type: 'item', - url: '/chatflows', - icon: icons.IconHierarchy, + url: '/agents', + icon: icons.IconRobot, breadcrumbs: true, - permission: 'chatflows:view' + permission: 'agents:view' }, { id: 'agentflows', @@ -93,12 +95,21 @@ const dashboard = { breadcrumbs: true, permission: 'executions:view' }, + { + id: 'chatflows', + title: 'Chatflows', + type: 'item', + url: '/chatflows', + icon: icons.IconHierarchy, + breadcrumbs: true, + permission: 'chatflows:view' + }, { id: 'assistants', title: 'Assistants', type: 'item', url: '/assistants', - icon: icons.IconRobot, + icon: icons.IconRobotFace, breadcrumbs: true, permission: 'assistants:view' }, diff --git a/packages/ui/src/routes/DefaultRedirect.jsx b/packages/ui/src/routes/DefaultRedirect.jsx index 6af3d10691a..8daaa827a7a 100644 --- a/packages/ui/src/routes/DefaultRedirect.jsx +++ b/packages/ui/src/routes/DefaultRedirect.jsx @@ -7,6 +7,7 @@ import Account from '@/views/account' import Executions from '@/views/agentexecutions' import Agentflows from '@/views/agentflows' import APIKey from '@/views/apikey' +import Agents from '@/views/agents' import Assistants from '@/views/assistants' import Login from '@/views/auth/login' import LoginActivityPage from '@/views/auth/loginActivity' @@ -38,6 +39,7 @@ export const DefaultRedirect = () => { // Define the order of routes to check (based on the menu order in dashboard.js) const routesToCheck = [ + { component: Agents, permission: 'agents:view' }, { component: Chatflows, permission: 'chatflows:view' }, { component: Agentflows, permission: 'agentflows:view' }, { component: Executions, permission: 'executions:view' }, @@ -68,14 +70,14 @@ export const DefaultRedirect = () => { return } - // For open source, show chatflows (no permission checks) + // For open source, show agents (no permission checks) if (isOpenSource) { - return + return } - // For global admins, show chatflows (they have access to everything) + // For global admins, show agents (they have access to everything) if (isGlobal) { - return + return } // Check each route in order and return the first accessible component diff --git a/packages/ui/src/routes/MainRoutes.jsx b/packages/ui/src/routes/MainRoutes.jsx index 544433db603..679340652ab 100644 --- a/packages/ui/src/routes/MainRoutes.jsx +++ b/packages/ui/src/routes/MainRoutes.jsx @@ -6,6 +6,7 @@ import Loadable from '@/ui-component/loading/Loadable' import { RequireAuth } from '@/routes/RequireAuth' import { DefaultRedirect } from '@/routes/DefaultRedirect' +import { Navigate } from 'react-router-dom' // chatflows routing const Chatflows = Loadable(lazy(() => import('@/views/chatflows'))) @@ -22,11 +23,15 @@ const APIKey = Loadable(lazy(() => import('@/views/apikey'))) // tools routing const Tools = Loadable(lazy(() => import('@/views/tools'))) +// agents routing (custom assistants rebranded) +const Agents = Loadable(lazy(() => import('@/views/agents'))) +const AgentConfigurePreview = Loadable(lazy(() => import('@/views/agents/AgentConfigurePreview'))) + // assistants routing const Assistants = Loadable(lazy(() => import('@/views/assistants'))) const OpenAIAssistantLayout = Loadable(lazy(() => import('@/views/assistants/openai/OpenAIAssistantLayout'))) const CustomAssistantLayout = Loadable(lazy(() => import('@/views/assistants/custom/CustomAssistantLayout'))) -const CustomAssistantConfigurePreview = Loadable(lazy(() => import('@/views/assistants/custom/CustomAssistantConfigurePreview'))) +// CustomAssistantConfigurePreview is now used via AgentConfigurePreview wrapper at /agents/:id // credentials routing const Credentials = Loadable(lazy(() => import('@/views/credentials'))) @@ -128,6 +133,30 @@ const MainRoutes = { ) }, + { + path: '/agents', + element: ( + + + + ) + }, + { + path: '/agents/:id', + element: ( + + + + ) + }, + { + path: '/marketplace/agents/:id', + element: ( + + + + ) + }, { path: '/assistants', element: ( @@ -146,11 +175,7 @@ const MainRoutes = { }, { path: '/assistants/custom/:id', - element: ( - - - - ) + element: }, { path: '/assistants/openai', diff --git a/packages/ui/src/store/constant.js b/packages/ui/src/store/constant.js index 627959fa1f2..043a98af815 100644 --- a/packages/ui/src/store/constant.js +++ b/packages/ui/src/store/constant.js @@ -19,6 +19,7 @@ import { export const gridSpacing = 3 export const drawerWidth = 260 +export const miniDrawerWidth = 72 export const appDrawerWidth = 320 export const headerHeight = 80 export const maxScroll = 100000 diff --git a/packages/ui/src/themes/compStyleOverride.js b/packages/ui/src/themes/compStyleOverride.js index c04cc3f17f4..18014c70572 100644 --- a/packages/ui/src/themes/compStyleOverride.js +++ b/packages/ui/src/themes/compStyleOverride.js @@ -45,8 +45,7 @@ export default function componentStyleOverrides(theme) { MuiSvgIcon: { styleOverrides: { root: { - color: theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit', - background: theme?.customization?.isDarkMode ? theme.colors?.darkPrimaryLight : 'inherit' + color: theme?.customization?.isDarkMode ? theme.colors?.paper : 'inherit' } } }, diff --git a/packages/ui/src/themes/index.js b/packages/ui/src/themes/index.js index b1067ab354a..b75949be575 100644 --- a/packages/ui/src/themes/index.js +++ b/packages/ui/src/themes/index.js @@ -37,7 +37,7 @@ export const theme = (customization) => { paper: color.paper, backgroundDefault: color.paper, background: color.primaryLight, - darkTextPrimary: color.grey700, + darkTextPrimary: color.grey800, darkTextSecondary: color.grey500, textDark: color.grey900, menuSelected: color.secondaryDark, diff --git a/packages/ui/src/ui-component/button/AgentListMenu.jsx b/packages/ui/src/ui-component/button/AgentListMenu.jsx new file mode 100644 index 00000000000..0f3d9b07ab1 --- /dev/null +++ b/packages/ui/src/ui-component/button/AgentListMenu.jsx @@ -0,0 +1,416 @@ +import { useState } from 'react' +import { useDispatch } from 'react-redux' +import PropTypes from 'prop-types' + +import { styled, alpha } from '@mui/material/styles' +import Menu from '@mui/material/Menu' +import { PermissionMenuItem } from '@/ui-component/button/RBACButtons' +import EditIcon from '@mui/icons-material/Edit' +import Divider from '@mui/material/Divider' +import FileCopyIcon from '@mui/icons-material/FileCopy' +import FileDownloadIcon from '@mui/icons-material/Downloading' +import FileDeleteIcon from '@mui/icons-material/Delete' +import FileCategoryIcon from '@mui/icons-material/Category' +import PictureInPictureAltIcon from '@mui/icons-material/PictureInPictureAlt' +import ThumbsUpDownOutlinedIcon from '@mui/icons-material/ThumbsUpDownOutlined' +import VpnLockOutlinedIcon from '@mui/icons-material/VpnLockOutlined' +import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined' +import ExportTemplateOutlinedIcon from '@mui/icons-material/BookmarksOutlined' +import Button from '@mui/material/Button' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import { IconX } from '@tabler/icons-react' + +import chatflowsApi from '@/api/chatflows' + +import useApi from '@/hooks/useApi' +import useConfirm from '@/hooks/useConfirm' +import { uiBaseURL } from '@/store/constant' +import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions' + +import SaveChatflowDialog from '@/ui-component/dialog/SaveChatflowDialog' +import TagDialog from '@/ui-component/dialog/TagDialog' +import StarterPromptsDialog from '@/ui-component/dialog/StarterPromptsDialog' +import ChatFeedbackDialog from '@/ui-component/dialog/ChatFeedbackDialog' +import AllowedDomainsDialog from '@/ui-component/dialog/AllowedDomainsDialog' +import SpeechToTextDialog from '@/ui-component/dialog/SpeechToTextDialog' +import ExportAsTemplateDialog from '@/ui-component/dialog/ExportAsTemplateDialog' + +import { generateExportFlowData } from '@/utils/genericHelper' +import useNotifier from '@/utils/useNotifier' + +const StyledMenu = styled((props) => ( + +))(({ theme }) => ({ + '& .MuiPaper-root': { + borderRadius: 6, + marginTop: theme.spacing(1), + minWidth: 180, + boxShadow: + 'rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px', + '& .MuiMenu-list': { + padding: '4px 0' + }, + '& .MuiMenuItem-root': { + '& .MuiSvgIcon-root': { + fontSize: 18, + color: theme.palette.text.secondary, + marginRight: theme.spacing(1.5) + }, + '&:active': { + backgroundColor: alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity) + } + } + } +})) + +export default function AgentListMenu({ agent, setError, onRefresh }) { + const { confirm } = useConfirm() + const dispatch = useDispatch() + const updateChatflowApi = useApi(chatflowsApi.updateChatflow) + + useNotifier() + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const [flowDialogOpen, setFlowDialogOpen] = useState(false) + const [exportTemplateDialogOpen, setExportTemplateDialogOpen] = useState(false) + const [exportTemplateDialogProps, setExportTemplateDialogProps] = useState({}) + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false) + const [categoryDialogProps, setCategoryDialogProps] = useState({}) + const [conversationStartersDialogOpen, setConversationStartersDialogOpen] = useState(false) + const [conversationStartersDialogProps, setConversationStartersDialogProps] = useState({}) + const [chatFeedbackDialogOpen, setChatFeedbackDialogOpen] = useState(false) + const [chatFeedbackDialogProps, setChatFeedbackDialogProps] = useState({}) + const [allowedDomainsDialogOpen, setAllowedDomainsDialogOpen] = useState(false) + const [allowedDomainsDialogProps, setAllowedDomainsDialogProps] = useState({}) + const [speechToTextDialogOpen, setSpeechToTextDialogOpen] = useState(false) + const [speechToTextDialogProps, setSpeechToTextDialogProps] = useState({}) + + const handleClick = (event) => { + event.stopPropagation() + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + const refreshAgents = () => { + if (onRefresh) onRefresh() + } + + const handleRename = () => { + setAnchorEl(null) + setFlowDialogOpen(true) + } + + const saveRename = async (newName) => { + try { + await updateChatflowApi.request(agent.id, { name: newName }) + refreshAgents() + } catch (error) { + if (setError) setError(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) => ( + + ) + } + }) + } + } + + const getFlowData = () => { + return agent.flowData || null + } + + const handleDuplicate = async () => { + setAnchorEl(null) + try { + const flowData = getFlowData() + if (!flowData) return + const saveObj = { + name: `${agent.name} (Copy)`, + flowData: flowData, + type: 'AGENT' + } + const createResp = await chatflowsApi.createNewChatflow(saveObj) + if (createResp.data) { + window.open(`${uiBaseURL}/agents/${createResp.data.id}`, '_blank') + } + } catch (e) { + console.error(e) + enqueueSnackbar({ + message: `Failed to duplicate agent: ${e.message || 'Unknown error'}`, + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) + } + } + + const handleExport = () => { + setAnchorEl(null) + try { + const flowDataStr = getFlowData() + if (!flowDataStr) return + const flowData = JSON.parse(flowDataStr) + const dataStr = JSON.stringify(generateExportFlowData(flowData, 'AGENT'), null, 2) + const blob = new Blob([dataStr], { type: 'application/json' }) + const dataUri = URL.createObjectURL(blob) + const exportFileDefaultName = `${agent.name || 'Agent'} Agent.json` + const linkElement = document.createElement('a') + linkElement.setAttribute('href', dataUri) + linkElement.setAttribute('download', exportFileDefaultName) + linkElement.click() + } catch (e) { + console.error(e) + } + } + + const handleExportTemplate = () => { + setAnchorEl(null) + setExportTemplateDialogProps({ chatflow: agent }) + setExportTemplateDialogOpen(true) + } + + const handleStarterPrompts = () => { + setAnchorEl(null) + setConversationStartersDialogProps({ + title: 'Starter Prompts - ' + agent.name, + chatflow: agent + }) + setConversationStartersDialogOpen(true) + } + + const handleChatFeedback = () => { + setAnchorEl(null) + setChatFeedbackDialogProps({ + title: 'Chat Feedback - ' + agent.name, + chatflow: agent + }) + setChatFeedbackDialogOpen(true) + } + + const handleAllowedDomains = () => { + setAnchorEl(null) + setAllowedDomainsDialogProps({ + title: 'Allowed Domains - ' + agent.name, + chatflow: agent + }) + setAllowedDomainsDialogOpen(true) + } + + const handleSpeechToText = () => { + setAnchorEl(null) + setSpeechToTextDialogProps({ + title: 'Speech To Text - ' + agent.name, + chatflow: agent + }) + setSpeechToTextDialogOpen(true) + } + + const handleCategory = () => { + setAnchorEl(null) + if (agent.category) { + setCategoryDialogProps({ + category: agent.category.split(';') + }) + } + setCategoryDialogOpen(true) + } + + const saveCategory = async (categories) => { + setCategoryDialogOpen(false) + const categoryTags = categories.join(';') + try { + await updateChatflowApi.request(agent.id, { category: categoryTags }) + await refreshAgents() + } catch (error) { + if (setError) setError(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) => ( + + ) + } + }) + } + } + + const handleDelete = async () => { + setAnchorEl(null) + const confirmPayload = { + title: `Delete`, + description: `Delete Agent ${agent.name}?`, + confirmButtonName: 'Delete', + cancelButtonName: 'Cancel' + } + const isConfirmed = await confirm(confirmPayload) + + if (isConfirmed) { + try { + await chatflowsApi.deleteChatflow(agent.id) + refreshAgents() + } catch (error) { + if (setError) setError(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) => ( + + ) + } + }) + } + } + } + + return ( +
+ + + + + Rename + + + + Duplicate + + + + Export + + + + Save As Template + + + + + Starter Prompts + + + + Chat Feedback + + + + Allowed Domains + + + + Speech To Text + + + + Update Category + + + + + Delete + + + setFlowDialogOpen(false)} + onConfirm={saveRename} + /> + setCategoryDialogOpen(false)} + onSubmit={saveCategory} + /> + setConversationStartersDialogOpen(false)} + onConfirm={refreshAgents} + /> + setChatFeedbackDialogOpen(false)} + onConfirm={refreshAgents} + /> + setAllowedDomainsDialogOpen(false)} + onConfirm={refreshAgents} + /> + setSpeechToTextDialogOpen(false)} + onConfirm={refreshAgents} + /> + {exportTemplateDialogOpen && ( + setExportTemplateDialogOpen(false)} + /> + )} +
+ ) +} + +AgentListMenu.propTypes = { + agent: PropTypes.object, + setError: PropTypes.func, + onRefresh: PropTypes.func +} diff --git a/packages/ui/src/ui-component/button/FlowListMenu.jsx b/packages/ui/src/ui-component/button/FlowListMenu.jsx index 198b7f8476f..1b221640b82 100644 --- a/packages/ui/src/ui-component/button/FlowListMenu.jsx +++ b/packages/ui/src/ui-component/button/FlowListMenu.jsx @@ -323,7 +323,7 @@ export default function FlowListMenu({ chatflow, isAgentCanvas, isAgentflowV2, s setAnchorEl(null) try { const flowData = JSON.parse(chatflow.flowData) - let dataStr = JSON.stringify(generateExportFlowData(flowData), null, 2) + let dataStr = JSON.stringify(generateExportFlowData(flowData, chatflow.type), null, 2) //let dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr) const blob = new Blob([dataStr], { type: 'application/json' }) const dataUri = URL.createObjectURL(blob) diff --git a/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx b/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx index 78d5ed63dca..c5b117c11c3 100644 --- a/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ChatflowConfigurationDialog.jsx @@ -1,11 +1,33 @@ import PropTypes from 'prop-types' -import { useState } from 'react' +import { useState, useMemo } from 'react' import { createPortal } from 'react-dom' -import { Box, Dialog, DialogContent, DialogTitle, Tabs, Tab } from '@mui/material' -import { tabsClasses } from '@mui/material/Tabs' +import { useSelector } from 'react-redux' +import { Box, Dialog, DialogContent, DialogTitle, Typography, IconButton } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { + IconX, + IconShieldLock, + IconWorldWww, + IconUserPlus, + IconMessageChatbot, + IconArrowForwardUp, + IconThumbUp, + IconMicrophone, + IconVolume, + IconUpload, + IconChartBar, + IconCode, + IconServer, + IconAdjustments +} from '@tabler/icons-react' +import PerfectScrollbar from 'react-perfect-scrollbar' + +// Section components import SpeechToText from '@/ui-component/extended/SpeechToText' import TextToSpeech from '@/ui-component/extended/TextToSpeech' -import Security from '@/ui-component/extended/Security' +import RateLimit from '@/ui-component/extended/RateLimit' +import AllowedDomains from '@/ui-component/extended/AllowedDomains' +import OverrideConfig from '@/ui-component/extended/OverrideConfig' import ChatFeedback from '@/ui-component/extended/ChatFeedback' import AnalyseFlow from '@/ui-component/extended/AnalyseFlow' import StarterPrompts from '@/ui-component/extended/StarterPrompts' @@ -13,142 +35,450 @@ import Leads from '@/ui-component/extended/Leads' import FollowUpPrompts from '@/ui-component/extended/FollowUpPrompts' import FileUpload from '@/ui-component/extended/FileUpload' import PostProcessing from '@/ui-component/extended/PostProcessing' +import McpServer from '@/ui-component/extended/McpServer' -const CHATFLOW_CONFIGURATION_TABS = [ - { - label: 'Security', - id: 'security' - }, - { - label: 'Starter Prompts', - id: 'conversationStarters' - }, +const CONFIGURATION_GROUPS = [ { - label: 'Follow-up Prompts', - id: 'followUpPrompts' + label: 'General', + sections: [ + { + label: 'Rate Limit', + id: 'rateLimit', + icon: IconShieldLock, + description: 'Limit API requests per time window' + }, + { + label: 'Allowed Domains', + id: 'allowedDomains', + icon: IconWorldWww, + description: 'Restrict chatbot to specific domains' + }, + { + label: 'Leads', + id: 'leads', + icon: IconUserPlus, + description: 'Capture visitor contact information' + } + ] }, { - label: 'Speech to Text', - id: 'speechToText' + label: 'Chat', + sections: [ + { + label: 'Starter Prompts', + id: 'conversationStarters', + icon: IconMessageChatbot, + description: 'Suggested prompts for new conversations' + }, + { + label: 'Follow-up Prompts', + id: 'followUpPrompts', + icon: IconArrowForwardUp, + description: 'Auto-generate follow-up questions' + }, + { + label: 'Chat Feedback', + id: 'chatFeedback', + icon: IconThumbUp, + description: 'Allow users to rate responses' + } + ] }, { - label: 'Text to Speech', - id: 'textToSpeech' + label: 'Media & Files', + sections: [ + { + label: 'Speech to Text', + id: 'speechToText', + icon: IconMicrophone, + description: 'Voice input transcription' + }, + { + label: 'Text to Speech', + id: 'textToSpeech', + icon: IconVolume, + description: 'Audio response playback' + }, + { + label: 'File Upload', + id: 'fileUpload', + icon: IconUpload, + description: 'Allow file uploads in chat' + } + ] }, { - label: 'Chat Feedback', - id: 'chatFeedback' - }, - { - label: 'Analyse Chatflow', - id: 'analyseChatflow' - }, - { - label: 'Leads', - id: 'leads' - }, - { - label: 'File Upload', - id: 'fileUpload' - }, - { - label: 'Post Processing', - id: 'postProcessing' + label: 'Advanced', + sections: [ + { + label: 'Analytics', + id: 'analyseChatflow', + icon: IconChartBar, + description: 'Connect analytics providers' + }, + { + label: 'Post Processing', + id: 'postProcessing', + icon: IconCode, + description: 'Custom JavaScript post-processing' + }, + { + label: 'MCP Server', + id: 'mcpServer', + icon: IconServer, + description: 'Model Context Protocol server' + }, + { + label: 'Override Config', + id: 'overrideConfig', + icon: IconAdjustments, + description: 'Override flow configuration via API' + } + ] } ] -function TabPanel(props) { - const { children, value, index, ...other } = props - return ( - - ) -} +function getSectionStatus(sectionId, chatflow) { + if (!chatflow) return false -TabPanel.propTypes = { - children: PropTypes.node, - index: PropTypes.number.isRequired, - value: PropTypes.number.isRequired -} + let chatbotConfig = {} + let apiConfig = {} + try { + chatbotConfig = chatflow.chatbotConfig ? JSON.parse(chatflow.chatbotConfig) : {} + apiConfig = chatflow.apiConfig ? JSON.parse(chatflow.apiConfig) : {} + } catch { + return false + } -function a11yProps(index) { - return { - id: `chatflow-config-tab-${index}`, - 'aria-controls': `chatflow-config-tabpanel-${index}` + switch (sectionId) { + case 'rateLimit': + return apiConfig?.rateLimit?.status === true + case 'allowedDomains': + return Array.isArray(chatbotConfig?.allowedOrigins) && chatbotConfig.allowedOrigins.some((o) => o && o.trim() !== '') + case 'leads': + return chatbotConfig?.leads?.status === true + case 'conversationStarters': { + const sp = chatbotConfig?.starterPrompts + if (!sp) return false + return Object.values(sp).some((entry) => entry?.prompt && entry.prompt.trim() !== '') + } + case 'followUpPrompts': + return chatbotConfig?.followUpPrompts?.status === true + case 'chatFeedback': + return chatbotConfig?.chatFeedback?.status === true + case 'speechToText': { + const stt = chatbotConfig?.speechToText + if (!stt) return false + return Object.values(stt).some((provider) => provider?.status === true) + } + case 'textToSpeech': { + const tts = chatbotConfig?.textToSpeech + if (!tts) return false + return Object.values(tts).some((provider) => provider?.status === true) + } + case 'fileUpload': + return chatbotConfig?.fullFileUpload?.status === true + case 'analyseChatflow': { + const ap = chatbotConfig?.analyticsProviders + if (!ap) return false + return Object.values(ap).some((provider) => provider?.status === true) + } + case 'postProcessing': + return chatbotConfig?.postProcessing?.status === true + case 'mcpServer': + return chatbotConfig?.mcpServer?.status === true + case 'overrideConfig': + return apiConfig?.overrideConfig?.status === true + default: + return false } } +// Flatten all sections for quick lookup +const ALL_SECTIONS = CONFIGURATION_GROUPS.flatMap((g) => g.sections) + +const SIDEBAR_WIDTH = 220 + const ChatflowConfigurationDialog = ({ show, isAgentCanvas, dialogProps, onCancel }) => { const portalElement = document.getElementById('portal') - const [tabValue, setTabValue] = useState(0) + const theme = useTheme() + const chatflow = useSelector((state) => state.canvas.chatflow) + const customization = useSelector((state) => state.customization) - const filteredTabs = CHATFLOW_CONFIGURATION_TABS.filter((tab) => !isAgentCanvas || !tab.hideInAgentFlow) + const [activeSection, setActiveSection] = useState('rateLimit') + const [mcpServerEnabled, setMcpServerEnabled] = useState(false) + + const isDark = theme.palette.mode === 'dark' || customization?.isDarkMode + + // Filter groups/sections based on agent canvas + const filteredGroups = useMemo(() => { + return CONFIGURATION_GROUPS.map((group) => ({ + ...group, + sections: group.sections.filter((section) => !isAgentCanvas || !section.hideInAgentFlow) + })).filter((group) => group.sections.length > 0) + }, [isAgentCanvas]) + + // Get all section IDs for validation + const allSectionIds = useMemo(() => { + return filteredGroups.flatMap((g) => g.sections.map((s) => s.id)) + }, [filteredGroups]) + + // Reset activeSection if current one is filtered out + const currentSection = allSectionIds.includes(activeSection) ? activeSection : allSectionIds[0] || 'rateLimit' + const currentSectionData = ALL_SECTIONS.find((s) => s.id === currentSection) + + const renderContent = () => { + const props = { dialogProps } + switch (currentSection) { + case 'rateLimit': + return + case 'allowedDomains': + return + case 'leads': + return + case 'conversationStarters': + return + case 'followUpPrompts': + return + case 'chatFeedback': + return + case 'speechToText': + return + case 'textToSpeech': + return + case 'fileUpload': + return + case 'analyseChatflow': + return + case 'postProcessing': + return + case 'mcpServer': + return setMcpServerEnabled(enabled)} /> + case 'overrideConfig': + return + default: + return null + } + } const component = show ? ( - - {dialogProps.title} + {/* Header */} + + {dialogProps.title} + + + - - + {/* Sidebar */} + setTabValue(value)} - aria-label='tabs' - variant='scrollable' - scrollButtons='auto' > - {filteredTabs.map((item, index) => ( - - ))} - - {filteredTabs.map((item, index) => ( - - {item.id === 'security' && } - {item.id === 'conversationStarters' ? : null} - {item.id === 'followUpPrompts' ? : null} - {item.id === 'speechToText' ? : null} - {item.id === 'textToSpeech' ? : null} - {item.id === 'chatFeedback' ? : null} - {item.id === 'analyseChatflow' ? : null} - {item.id === 'leads' ? : null} - {item.id === 'fileUpload' ? : null} - {item.id === 'postProcessing' ? : null} - - ))} + + + {filteredGroups.map((group, groupIndex) => ( + + {/* Group label */} + 0 ? 2 : 0.5, + pb: 0.75 + }} + > + {group.label} + + + {/* Section items */} + {group.sections.map((section) => { + const isActive = currentSection === section.id + const isEnabled = + section.id === 'mcpServer' ? mcpServerEnabled : getSectionStatus(section.id, chatflow) + const SectionIcon = section.icon + + return ( + setActiveSection(section.id)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.25, + px: 1.5, + py: 0.875, + borderRadius: '8px', + cursor: 'pointer', + position: 'relative', + color: isActive ? 'primary.main' : isDark ? 'grey.300' : 'grey.700', + bgcolor: isActive + ? isDark + ? 'rgba(33, 150, 243, 0.12)' + : 'rgba(33, 150, 243, 0.06)' + : 'transparent', + transition: 'all 0.15s ease', + '&:hover': { + bgcolor: isActive + ? isDark + ? 'rgba(33, 150, 243, 0.16)' + : 'rgba(33, 150, 243, 0.08)' + : isDark + ? 'rgba(255,255,255,0.04)' + : 'rgba(0,0,0,0.03)' + }, + userSelect: 'none' + }} + > + {/* Icon */} + + + {/* Label */} + + {section.label} + + + {/* Status badge - only show when enabled */} + {isEnabled && ( + + ON + + )} + + ) + })} + + ))} + + + + + {/* Content area */} + + + + {/* Section header */} + + + {currentSectionData?.label || ''} + + {currentSectionData?.description && ( + + {currentSectionData.description} + + )} + + + {/* Section content */} + {renderContent()} + + + ) : null diff --git a/packages/ui/src/ui-component/dialog/ExpandTextDialog.jsx b/packages/ui/src/ui-component/dialog/ExpandTextDialog.jsx index 6981fd073da..80e96d4b129 100644 --- a/packages/ui/src/ui-component/dialog/ExpandTextDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ExpandTextDialog.jsx @@ -118,7 +118,7 @@ const ExpandTextDialog = ({ show, dialogProps, onCancel, onInputHintDialogClicke borderColor: theme.palette.grey['500'], borderRadius: '12px', height: '100%', - maxHeight: languageType === 'js' ? 'calc(100vh - 250px)' : 'calc(100vh - 220px)', + maxHeight: inputParam.type === 'code' ? 'calc(100vh - 250px)' : 'calc(100vh - 220px)', overflowX: 'hidden', backgroundColor: 'white' }} @@ -126,19 +126,19 @@ const ExpandTextDialog = ({ show, dialogProps, onCancel, onInputHintDialogClicke setInputValue(code)} /> diff --git a/packages/ui/src/ui-component/dialog/ExportAsTemplateDialog.jsx b/packages/ui/src/ui-component/dialog/ExportAsTemplateDialog.jsx index 53951768237..ba23962e752 100644 --- a/packages/ui/src/ui-component/dialog/ExportAsTemplateDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ExportAsTemplateDialog.jsx @@ -50,6 +50,8 @@ const ExportAsTemplateDialog = ({ show, dialogProps, onCancel }) => { setFlowType('Agentflow') } else if (dialogProps.chatflow.type === 'CHATFLOW') { setFlowType('Chatflow') + } else if (dialogProps.chatflow.type === 'AGENT' || dialogProps.chatflow.type === 'ASSISTANT') { + setFlowType('Agent') } } diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx index 41754a18c27..091640c50fc 100644 --- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx +++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx @@ -195,7 +195,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { const [sourceDialogProps, setSourceDialogProps] = useState({}) const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false) const [hardDeleteDialogProps, setHardDeleteDialogProps] = useState({}) - const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL']) + const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL', 'MCP']) const [feedbackTypeFilter, setFeedbackTypeFilter] = useState([]) const [startDate, setStartDate] = useState(new Date(new Date().setMonth(new Date().getMonth() - 1))) const [endDate, setEndDate] = useState(new Date()) @@ -347,6 +347,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { return 'UI' } else if (chatType === 'EVALUATION') { return 'Evaluation' + } else if (chatType === 'MCP') { + return 'MCP' } return 'API/Embed' } @@ -756,7 +758,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { return () => { setChatLogs([]) setChatMessages([]) - setChatTypeFilter(['INTERNAL', 'EXTERNAL']) + setChatTypeFilter(['INTERNAL', 'EXTERNAL', 'MCP']) setFeedbackTypeFilter([]) setSelectedMessageIndex(0) setSelectedChatId('') @@ -906,6 +908,10 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => { label: 'API/Embed', name: 'EXTERNAL' }, + { + label: 'MCP', + name: 'MCP' + }, { label: 'Evaluations', name: 'EVALUATION' diff --git a/packages/ui/src/ui-component/extended/AllowedDomains.jsx b/packages/ui/src/ui-component/extended/AllowedDomains.jsx index 75ae32a6334..29a44a5792e 100644 --- a/packages/ui/src/ui-component/extended/AllowedDomains.jsx +++ b/packages/ui/src/ui-component/extended/AllowedDomains.jsx @@ -17,7 +17,7 @@ import useNotifier from '@/utils/useNotifier' // API import chatflowsApi from '@/api/chatflows' -const AllowedDomains = ({ dialogProps, onConfirm }) => { +const AllowedDomains = ({ dialogProps, onConfirm, hideTitle = false }) => { const dispatch = useDispatch() useNotifier() @@ -119,14 +119,16 @@ const AllowedDomains = ({ dialogProps, onConfirm }) => { }, [dialogProps]) return ( - - - Allowed Domains - - + + {!hideTitle && ( + + Allowed Domains + + + )} Domains @@ -193,16 +195,19 @@ const AllowedDomains = ({ dialogProps, onConfirm }) => { /> - - Save - + + + Save + + ) } AllowedDomains.propTypes = { dialogProps: PropTypes.object, - onConfirm: PropTypes.func + onConfirm: PropTypes.func, + hideTitle: PropTypes.bool } export default AllowedDomains diff --git a/packages/ui/src/ui-component/extended/AnalyseFlow.jsx b/packages/ui/src/ui-component/extended/AnalyseFlow.jsx index de162e51a34..84038f4c69c 100644 --- a/packages/ui/src/ui-component/extended/AnalyseFlow.jsx +++ b/packages/ui/src/ui-component/extended/AnalyseFlow.jsx @@ -15,6 +15,7 @@ import { ListItemAvatar, ListItemText } from '@mui/material' +import { useTheme } from '@mui/material/styles' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { IconX } from '@tabler/icons-react' @@ -221,6 +222,7 @@ const analyticProviders = [ const AnalyseFlow = ({ dialogProps }) => { const dispatch = useDispatch() + const theme = useTheme() useNotifier() @@ -339,7 +341,12 @@ const AnalyseFlow = ({ dialogProps }) => { sx={{ ml: 1 }} primary={provider.label} secondary={ -
+ {provider.url} } @@ -416,9 +423,11 @@ const AnalyseFlow = ({ dialogProps }) => { ))} - - Save - + + + Save + + ) } diff --git a/packages/ui/src/ui-component/extended/ChatFeedback.jsx b/packages/ui/src/ui-component/extended/ChatFeedback.jsx index 132d073e2be..ca55dc5b718 100644 --- a/packages/ui/src/ui-component/extended/ChatFeedback.jsx +++ b/packages/ui/src/ui-component/extended/ChatFeedback.jsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import PropTypes from 'prop-types' // material-ui -import { Button, Box } from '@mui/material' +import { Button, Box, Stack } from '@mui/material' import { IconX } from '@tabler/icons-react' // Project import @@ -91,14 +91,14 @@ const ChatFeedback = ({ dialogProps, onConfirm }) => { }, [dialogProps]) return ( - <> - - + + + + + Save + - - Save - - + ) } diff --git a/packages/ui/src/ui-component/extended/FileUpload.jsx b/packages/ui/src/ui-component/extended/FileUpload.jsx index d688cb75460..fc561359088 100644 --- a/packages/ui/src/ui-component/extended/FileUpload.jsx +++ b/packages/ui/src/ui-component/extended/FileUpload.jsx @@ -5,7 +5,19 @@ import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackba import parser from 'html-react-parser' // material-ui -import { Button, Box, Typography, FormControl, RadioGroup, FormControlLabel, Radio } from '@mui/material' +import { + Button, + Box, + Typography, + FormControl, + RadioGroup, + FormControlLabel, + Radio, + Accordion, + AccordionSummary, + AccordionDetails +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { IconX, IconBulb } from '@tabler/icons-react' // Project import @@ -20,7 +32,7 @@ import chatflowsApi from '@/api/chatflows' const message = `The full contents of uploaded files will be converted to text and sent to the Agent.
-Refer docs for more details.` +Refer docs for more details.` const availableFileTypes = [ { name: 'CSS', ext: 'text/css', extension: '.css' }, @@ -52,8 +64,6 @@ const FileUpload = ({ dialogProps }) => { const [allowedFileTypes, setAllowedFileTypes] = useState([]) const [chatbotConfig, setChatbotConfig] = useState({}) const [pdfUsage, setPdfUsage] = useState('perPage') - const [pdfLegacyBuild, setPdfLegacyBuild] = useState(false) - const handleChange = (value) => { setFullFileUpload(value) } @@ -71,18 +81,13 @@ const FileUpload = ({ dialogProps }) => { setPdfUsage(event.target.value) } - const handleLegacyBuildChange = (value) => { - setPdfLegacyBuild(value) - } - const onSave = async () => { try { const value = { status: fullFileUpload, allowedUploadFileTypes: allowedFileTypes.join(','), pdfFile: { - usage: pdfUsage, - legacyBuild: pdfLegacyBuild + usage: pdfUsage } } chatbotConfig.fullFileUpload = value @@ -140,13 +145,8 @@ const FileUpload = ({ dialogProps }) => { const allowedFileTypes = chatbotConfig.fullFileUpload.allowedUploadFileTypes.split(',') setAllowedFileTypes(allowedFileTypes) } - if (chatbotConfig.fullFileUpload?.pdfFile) { - if (chatbotConfig.fullFileUpload.pdfFile.usage) { - setPdfUsage(chatbotConfig.fullFileUpload.pdfFile.usage) - } - if (chatbotConfig.fullFileUpload.pdfFile.legacyBuild !== undefined) { - setPdfLegacyBuild(chatbotConfig.fullFileUpload.pdfFile.legacyBuild) - } + if (chatbotConfig.fullFileUpload?.pdfFile?.usage) { + setPdfUsage(chatbotConfig.fullFileUpload.pdfFile.usage) } } catch (e) { setChatbotConfig({}) @@ -170,31 +170,27 @@ const FileUpload = ({ dialogProps }) => { mb: 2 }} > -
-
- - {parser(message)} -
-
+ + {parser(message)} +
- Allow Uploads of Type + Allow Uploads of Type
{ disabled={!fullFileUpload} onChange={handleAllowedFileTypesChange} /> -
))}
- {allowedFileTypes.includes('application/pdf') && fullFileUpload && ( - - } + sx={{ minHeight: 40, px: 2, '& .MuiAccordionSummary-content': { my: 0.75 } }} > - PDF Configuration - - - - PDF Usage - - - } label='One document per page' /> - } label='One document per file' /> - - - - - - - - + Advanced Settings + + + {/* PDF Processing */} + {allowedFileTypes.includes('application/pdf') && ( + + + PDF Processing + + + + } + label={One document per page} + /> + } + label={One document per file} + /> + + + + )} + + )} - - Save - + + + Save + + ) } diff --git a/packages/ui/src/ui-component/extended/FollowUpPrompts.jsx b/packages/ui/src/ui-component/extended/FollowUpPrompts.jsx index bc0d95bc1a5..8a7a711b88e 100644 --- a/packages/ui/src/ui-component/extended/FollowUpPrompts.jsx +++ b/packages/ui/src/ui-component/extended/FollowUpPrompts.jsx @@ -601,9 +601,11 @@ const FollowUpPrompts = ({ dialogProps }) => { )} - - Save - + + + Save + + ) } diff --git a/packages/ui/src/ui-component/extended/Leads.jsx b/packages/ui/src/ui-component/extended/Leads.jsx index 395902986c8..58fa8169bb1 100644 --- a/packages/ui/src/ui-component/extended/Leads.jsx +++ b/packages/ui/src/ui-component/extended/Leads.jsx @@ -160,14 +160,16 @@ const Leads = ({ dialogProps }) => { )} - - Save - + + + Save + + ) } diff --git a/packages/ui/src/ui-component/extended/McpServer.jsx b/packages/ui/src/ui-component/extended/McpServer.jsx new file mode 100644 index 00000000000..b712130a9d7 --- /dev/null +++ b/packages/ui/src/ui-component/extended/McpServer.jsx @@ -0,0 +1,418 @@ +import { useDispatch, useSelector } from 'react-redux' +import { useState, useEffect } from 'react' +import PropTypes from 'prop-types' + +// material-ui +import { Button, Box, Typography, IconButton, OutlinedInput, InputAdornment, Alert } from '@mui/material' +import { IconX, IconCopy, IconRefresh } from '@tabler/icons-react' +import { useTheme } from '@mui/material/styles' + +// Project import +import { StyledButton } from '@/ui-component/button/StyledButton' +import { SwitchInput } from '@/ui-component/switch/Switch' + +// Hooks +import useConfirm from '@/hooks/useConfirm' +import useApi from '@/hooks/useApi' + +// store +import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction, SET_CHATFLOW } from '@/store/actions' +import useNotifier from '@/utils/useNotifier' + +// API +import mcpServerApi from '@/api/mcpserver' +import chatflowsApi from '@/api/chatflows' + +const McpServer = ({ dialogProps, onStatusChange }) => { + const dispatch = useDispatch() + const theme = useTheme() + const customization = useSelector((state) => state.customization) + const { confirm } = useConfirm() + + useNotifier() + + const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) + const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) + + const [mcpEnabled, setMcpEnabled] = useState(false) + const [toolName, setToolName] = useState('') + const [description, setDescription] = useState('') + const [token, setToken] = useState('') + const [loading, setLoading] = useState(false) + const [hasExistingConfig, setHasExistingConfig] = useState(false) + + const getMcpServerConfigApi = useApi(mcpServerApi.getMcpServerConfig) + const [toolNameError, setToolNameError] = useState('') + + const chatflowId = dialogProps?.chatflow?.id + const endpointUrl = chatflowId ? `${window.location.origin}/api/v1/mcp/${chatflowId}` : '' + + const validateToolName = (name) => { + if (!name) return 'Tool name is required' + if (name.length > 64) return 'Tool name must be 64 characters or less' + if (!/^[A-Za-z0-9_-]+$/.test(name)) return 'Only letters, numbers, underscores, and hyphens allowed' + return '' + } + + const handleToolNameChange = (value) => { + setToolName(value) + setToolNameError(validateToolName(value)) + } + + const showSuccess = (message) => { + enqueueSnackbar({ + message, + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + } + + const showError = (message) => { + enqueueSnackbar({ + message, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + } + + const refreshChatflowStore = async () => { + try { + const resp = await chatflowsApi.getSpecificChatflow(dialogProps.chatflow.id) + if (resp.data) { + dispatch({ type: SET_CHATFLOW, chatflow: resp.data }) + } + } catch { + // silent fail — the store will refresh on next navigation + } + } + + const handleToggle = (enabled) => { + setMcpEnabled(enabled) + } + + const onSave = async () => { + if (!dialogProps.chatflow?.id) return + if (mcpEnabled && (toolNameError || !toolName.trim() || !description.trim())) return + + setLoading(true) + try { + if (mcpEnabled) { + if (hasExistingConfig) { + const resp = await mcpServerApi.updateMcpServerConfig(dialogProps.chatflow.id, { + enabled: true, + toolName: toolName || undefined, + description: description || undefined + }) + if (resp.data) { + setMcpEnabled(resp.data.enabled) + setToken(resp.data.token || '') + setToolName(resp.data.toolName || '') + setDescription(resp.data.description || '') + onStatusChange?.(resp.data.enabled) + showSuccess('MCP Server settings saved') + } + } else { + const resp = await mcpServerApi.createMcpServerConfig(dialogProps.chatflow.id, { + toolName: toolName || undefined, + description: description || undefined + }) + if (resp.data) { + setMcpEnabled(resp.data.enabled) + setToken(resp.data.token || '') + setToolName(resp.data.toolName || '') + setDescription(resp.data.description || '') + setHasExistingConfig(true) + onStatusChange?.(resp.data.enabled) + showSuccess('MCP Server settings saved') + } + } + } else { + await mcpServerApi.deleteMcpServerConfig(dialogProps.chatflow.id) + setMcpEnabled(false) + onStatusChange?.(false) + showSuccess('MCP Server disabled') + } + await refreshChatflowStore() + } catch (error) { + showError( + `Failed to save MCP Server settings: ${ + typeof error.response?.data === 'object' ? error.response.data.message : error.response?.data || error.message + }` + ) + } finally { + setLoading(false) + } + } + + const handleCopyUrl = (url) => { + if (!url) return + navigator.clipboard.writeText(url) + showSuccess('URL copied to clipboard') + } + + const handleRefreshCode = async () => { + const confirmPayload = { + title: 'Rotate Token', + description: + 'This will invalidate the existing token. Any clients using the old token will need to be updated with the new one. Are you sure?', + confirmButtonName: 'Rotate', + cancelButtonName: 'Cancel' + } + const isConfirmed = await confirm(confirmPayload) + if (!isConfirmed) return + if (!dialogProps.chatflow?.id) return + + setLoading(true) + try { + const resp = await mcpServerApi.refreshMcpToken(dialogProps.chatflow.id) + if (resp.data) { + setToken(resp.data.token || '') + showSuccess('Token rotated successfully') + } + await refreshChatflowStore() + } catch (error) { + showError( + `Failed to rotate token: ${ + typeof error.response?.data === 'object' ? error.response.data.message : error.response?.data || error.message + }` + ) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (dialogProps.chatflow?.id) { + getMcpServerConfigApi.request(dialogProps.chatflow.id) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dialogProps]) + + useEffect(() => { + if (getMcpServerConfigApi.error) { + showError( + `Failed to load MCP Server configuration: ${ + typeof getMcpServerConfigApi.error.response?.data === 'object' + ? getMcpServerConfigApi.error.response.data.message + : getMcpServerConfigApi.error.response?.data || getMcpServerConfigApi.error.message + }` + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getMcpServerConfigApi.error]) + + useEffect(() => { + if (getMcpServerConfigApi.data) { + const enabled = getMcpServerConfigApi.data.enabled || false + setMcpEnabled(enabled) + setToolName(getMcpServerConfigApi.data.toolName || '') + setDescription(getMcpServerConfigApi.data.description || '') + setToken(getMcpServerConfigApi.data.token || '') + setHasExistingConfig(!!getMcpServerConfigApi.data.token) + onStatusChange?.(enabled) + } + }, [getMcpServerConfigApi.data]) // eslint-disable-line react-hooks/exhaustive-deps + + if (getMcpServerConfigApi.loading) { + return ( + + Loading MCP Server configuration... + + ) + } + + return ( + <> + + + + + {mcpEnabled && ( + + {/* Tool Name (required) */} + + + Tool Name * + + handleToolNameChange(e.target.value)} + placeholder='e.g. product_qa' + error={!!toolNameError} + disabled={loading} + /> + {toolNameError && ( + + {toolNameError} + + )} + + Used as the MCP tool identifier by LLM clients. + + + + {/* Description (required) */} + + + Description * + + setDescription(e.target.value)} + placeholder='e.g. Answers product catalog questions' + disabled={loading} + /> + + Helps LLMs understand when to route queries to this tool. Good descriptions improve tool selection accuracy. + + + + {/* MCP Endpoint URL — visible only when has token */} + {token && ( + + Streamable HTTP Endpoint + + handleCopyUrl(endpointUrl)} + title='Copy URL to clipboard' + sx={{ color: customization.isDarkMode ? theme.palette.grey[300] : 'inherit' }} + > + + + + } + /> + + For clients that support the Streamable HTTP transport + + + Token (Bearer Token) + + { + navigator.clipboard.writeText(token) + showSuccess('Token copied to clipboard') + }} + title='Copy token' + sx={{ color: customization.isDarkMode ? theme.palette.grey[300] : 'inherit' }} + > + + + + + + + } + /> + + Use the URL above as the MCP endpoint and pass the token as a Bearer token in the Authorization header. + Configure your MCP client with:{' '} + + Authorization: Bearer {''} + + + + )} + + )} + + + + {loading ? 'Saving...' : 'Save'} + + + + ) +} + +McpServer.propTypes = { + dialogProps: PropTypes.object, + onStatusChange: PropTypes.func +} + +export default McpServer diff --git a/packages/ui/src/ui-component/extended/OverrideConfig.jsx b/packages/ui/src/ui-component/extended/OverrideConfig.jsx index 32397b4b973..0ca1b4c85fd 100644 --- a/packages/ui/src/ui-component/extended/OverrideConfig.jsx +++ b/packages/ui/src/ui-component/extended/OverrideConfig.jsx @@ -5,6 +5,7 @@ import { Accordion, AccordionDetails, AccordionSummary, + Box, Button, Paper, Stack, @@ -17,7 +18,6 @@ import { Typography, Card } from '@mui/material' -import { useTheme } from '@mui/material/styles' // Project import import { StyledButton } from '@/ui-component/button/StyledButton' @@ -39,6 +39,9 @@ import variablesApi from '@/api/variables' // utils const OverrideConfigTable = ({ columns, onToggle, rows, sx }) => { + const customization = useSelector((state) => state.customization) + const isDark = customization?.isDarkMode + const handleChange = (enabled, row) => { onToggle(row, enabled) } @@ -47,10 +50,8 @@ const OverrideConfigTable = ({ columns, onToggle, rows, sx }) => { if (key === 'enabled') { return handleChange(enabled, row)} value={row.enabled} /> } else if (key === 'type' && row.schema) { - // If there's schema information, add a tooltip let schemaContent if (Array.isArray(row.schema)) { - // Handle array format: [{ name: "field", type: "string" }, ...] schemaContent = '[
' + row.schema @@ -67,30 +68,53 @@ const OverrideConfigTable = ({ columns, onToggle, rows, sx }) => { .join(',
') + '
]' } else if (typeof row.schema === 'object' && row.schema !== null) { - // Handle object format: { "field": "string", "field2": "number", ... } schemaContent = JSON.stringify(row.schema, null, 2).replace(/\n/g, '
').replace(/ /g, ' ') } else { schemaContent = 'No schema available' } return ( - - {row[key]} + + {row[key]} Schema:
${schemaContent}
`} /> ) } else { - return row[key] + return {row[key]} } } + const columnLabels = { label: 'Label', name: 'Name', type: 'Type', enabled: 'On' } + return ( - - + +
{columns.map((col, index) => ( - {col.charAt(0).toUpperCase() + col.slice(1)} + + {columnLabels[col] || col.charAt(0).toUpperCase() + col.slice(1)} + ))} @@ -99,7 +123,19 @@ const OverrideConfigTable = ({ columns, onToggle, rows, sx }) => { {Object.keys(row).map((key, index) => { if (key !== 'id' && key !== 'schema') { - return {renderCellContent(key, row)} + return ( + + {renderCellContent(key, row)} + + ) } })} @@ -117,14 +153,14 @@ OverrideConfigTable.propTypes = { onToggle: PropTypes.func } -const OverrideConfig = ({ dialogProps }) => { +const OverrideConfig = ({ dialogProps, hideTitle = false }) => { const dispatch = useDispatch() + const customization = useSelector((state) => state.customization) const chatflow = useSelector((state) => state.canvas.chatflow) const chatflowid = chatflow.id const apiConfig = chatflow.apiConfig ? JSON.parse(chatflow.apiConfig) : {} useNotifier() - const theme = useTheme() const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args)) const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args)) @@ -359,22 +395,32 @@ const OverrideConfig = ({ dialogProps }) => { }, [getAllVariablesApi.data]) return ( - - - Override Configuration - documentation for more information.' - } - /> - + + {!hideTitle && ( + + Override Configuration + documentation for more information.' + } + /> + + )} {overrideConfigStatus && ( <> {nodeOverrides && nodeConfig && ( - + Nodes @@ -388,6 +434,11 @@ const OverrideConfig = ({ dialogProps }) => { onChange={handleAccordionChange(nodeLabel)} key={nodeLabel} disableGutters + sx={{ + '&:before': { + bgcolor: customization.isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)' + } + }} > } @@ -444,7 +495,15 @@ const OverrideConfig = ({ dialogProps }) => { )} {variableOverrides && variableOverrides.length > 0 && ( - + Variables @@ -459,15 +518,18 @@ const OverrideConfig = ({ dialogProps }) => { )} - - Save - + + + Save + + ) } OverrideConfig.propTypes = { - dialogProps: PropTypes.object + dialogProps: PropTypes.object, + hideTitle: PropTypes.bool } export default OverrideConfig diff --git a/packages/ui/src/ui-component/extended/PostProcessing.jsx b/packages/ui/src/ui-component/extended/PostProcessing.jsx index fb9543a55d5..e6cab9400df 100644 --- a/packages/ui/src/ui-component/extended/PostProcessing.jsx +++ b/packages/ui/src/ui-component/extended/PostProcessing.jsx @@ -179,7 +179,7 @@ const PostProcessing = ({ dialogProps }) => { style={{ marginTop: '10px', border: '1px solid', - borderColor: theme.palette.grey['300'], + borderColor: customization.isDarkMode ? 'rgba(255,255,255,0.12)' : theme.palette.grey['300'], borderRadius: '6px', height: '200px', width: '100%' @@ -196,7 +196,16 @@ const PostProcessing = ({ dialogProps }) => { /> - + { }} > }> - Available Variables + Available Variables - -
+ +
- Variable - Type - Description + + Variable + + + Type + + + Description + - + $flow.rawOutput @@ -283,11 +334,11 @@ const PostProcessing = ({ dialogProps }) => { List of artifacts generated during execution - + $flow.fileAnnotations - array - File annotations associated with the response + array + File annotations associated with the response
@@ -295,14 +346,16 @@ const PostProcessing = ({ dialogProps }) => { - - Save - + + + Save + + { +const RateLimit = ({ dialogProps, hideTitle = false }) => { const dispatch = useDispatch() const chatflow = useSelector((state) => state.canvas.chatflow) const chatflowid = chatflow.id @@ -147,36 +147,39 @@ const RateLimit = ({ dialogProps }) => { } return ( - - - Rate Limit{' '} - Rate Limit Setup Guide to set up Rate Limit correctly in your hosting environment.' - } - /> - - - - {rateLimitStatus && ( - - {textField(limitMax, 'limitMax', 'Message Limit per Duration', 'number', '5')} - {textField(limitDuration, 'limitDuration', 'Duration in Second', 'number', '60')} - {textField(limitMsg, 'limitMsg', 'Limit Message', 'string', 'You have reached the quota')} - - )} - - onSave()} sx={{ width: 'auto' }}> - Save - + + {!hideTitle && ( + + Rate Limit{' '} + Rate Limit Setup Guide to set up Rate Limit correctly in your hosting environment.' + } + /> + + )} + + {rateLimitStatus && ( + + {textField(limitMax, 'limitMax', 'Message Limit per Duration', 'number', '5')} + {textField(limitDuration, 'limitDuration', 'Duration in Second', 'number', '60')} + {textField(limitMsg, 'limitMsg', 'Limit Message', 'string', 'You have reached the quota')} + + )} + + onSave()} sx={{ minWidth: 100 }}> + Save + + ) } RateLimit.propTypes = { isSessionMemory: PropTypes.bool, - dialogProps: PropTypes.object + dialogProps: PropTypes.object, + hideTitle: PropTypes.bool } export default RateLimit diff --git a/packages/ui/src/ui-component/extended/SpeechToText.jsx b/packages/ui/src/ui-component/extended/SpeechToText.jsx index 2c732b2d02b..2ca7fd95c28 100644 --- a/packages/ui/src/ui-component/extended/SpeechToText.jsx +++ b/packages/ui/src/ui-component/extended/SpeechToText.jsx @@ -478,14 +478,16 @@ const SpeechToText = ({ dialogProps, onConfirm }) => { ))} )} - - Save - + + + Save + + ) } diff --git a/packages/ui/src/ui-component/extended/StarterPrompts.jsx b/packages/ui/src/ui-component/extended/StarterPrompts.jsx index 31504fc59e6..861bea1e125 100644 --- a/packages/ui/src/ui-component/extended/StarterPrompts.jsx +++ b/packages/ui/src/ui-component/extended/StarterPrompts.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import { enqueueSnackbar as enqueueSnackbarAction, closeSnackbar as closeSnackbarAction, SET_CHATFLOW } from '@/store/actions' // material-ui -import { Button, IconButton, OutlinedInput, Box, List, InputAdornment } from '@mui/material' +import { Button, IconButton, OutlinedInput, Box, List, InputAdornment, Typography } from '@mui/material' import { IconX, IconTrash, IconPlus, IconBulb } from '@tabler/icons-react' // Project import @@ -133,28 +133,24 @@ const StarterPrompts = ({ dialogProps, onConfirm }) => { return ( <> -
-
- - - Starter prompts will only be shown when there is no messages on the chat - -
-
+ + + Starter prompts will only be shown when there are no messages on the chat + + :not(style)': { m: 1 }, pt: 2 }}> {inputFields.map((data, index) => { @@ -199,9 +195,11 @@ const StarterPrompts = ({ dialogProps, onConfirm }) => { })} - - Save - + + + Save + + ) } diff --git a/packages/ui/src/ui-component/extended/TextToSpeech.jsx b/packages/ui/src/ui-component/extended/TextToSpeech.jsx index 226d45a1e17..8ce171615a4 100644 --- a/packages/ui/src/ui-component/extended/TextToSpeech.jsx +++ b/packages/ui/src/ui-component/extended/TextToSpeech.jsx @@ -641,14 +641,16 @@ const TextToSpeech = ({ dialogProps }) => { )} - - Save - + + + Save + + ) } diff --git a/packages/ui/src/ui-component/table/ExecutionsListTable.jsx b/packages/ui/src/ui-component/table/ExecutionsListTable.jsx index 1b7dd68f996..e2592df7197 100644 --- a/packages/ui/src/ui-component/table/ExecutionsListTable.jsx +++ b/packages/ui/src/ui-component/table/ExecutionsListTable.jsx @@ -185,7 +185,7 @@ export const ExecutionsListTable = ({ data, isLoading, onExecutionRowClick, onSe handleRequestSort('name')}> - Agentflow + Agents Session diff --git a/packages/ui/src/utils/exportImport.js b/packages/ui/src/utils/exportImport.js index a9361a7faac..c3fb4120af7 100644 --- a/packages/ui/src/utils/exportImport.js +++ b/packages/ui/src/utils/exportImport.js @@ -22,7 +22,7 @@ const sanitizeTool = (Tool) => { const sanitizeChatflow = (ChatFlow) => { try { return ChatFlow.map((chatFlow) => { - const sanitizeFlowData = generateExportFlowData(JSON.parse(chatFlow.flowData)) + const sanitizeFlowData = generateExportFlowData(JSON.parse(chatFlow.flowData), chatFlow.type) return { id: chatFlow.id, name: chatFlow.name, diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js index ac834c77f19..b90f83eddc3 100644 --- a/packages/ui/src/utils/genericHelper.js +++ b/packages/ui/src/utils/genericHelper.js @@ -567,12 +567,13 @@ const _removeCredentialId = (obj) => { const newObj = {} for (const [key, value] of Object.entries(obj)) { if (key === 'FLOWISE_CREDENTIAL_ID') continue + if (key === 'credential') continue newObj[key] = _removeCredentialId(value) } return newObj } -export const generateExportFlowData = (flowData) => { +export const generateExportFlowData = (flowData, type) => { const nodes = flowData.nodes const edges = flowData.edges @@ -620,6 +621,7 @@ export const generateExportFlowData = (flowData) => { nodes, edges } + if (type) exportJson.type = type return exportJson } diff --git a/packages/ui/src/views/agentexecutions/ExecutionDetails.jsx b/packages/ui/src/views/agentexecutions/ExecutionDetails.jsx index c89a1130f01..32c1c6fbc02 100644 --- a/packages/ui/src/views/agentexecutions/ExecutionDetails.jsx +++ b/packages/ui/src/views/agentexecutions/ExecutionDetails.jsx @@ -31,6 +31,7 @@ import { IconLoader, IconCircleXFilled, IconRelationOneToManyFilled, + IconRobot, IconShare, IconWorld, IconX @@ -147,7 +148,10 @@ function CustomLabel({ icon: Icon, itemStatus, children, name, ...other }) { } // Otherwise display the node icon - const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === name) + const foundIcon = + name === 'smartAgentAgentflow' + ? { icon: IconRobot, color: '#9575CD' } + : AGENTFLOW_ICONS.find((icon) => icon.name === name) if (foundIcon) { return ( ({ id: node.uniqueNodeId, - label: node.nodeLabel, + label: node.data?.name === 'smartAgentAgentflow' ? 'Agent' : node.nodeLabel, name: node.data?.name, status: node.status, data: node.data, @@ -746,7 +750,15 @@ export const ExecutionDetails = ({ open, isPublic, execution, metadata, onClose, variant='outlined' label={localMetadata?.agentflow?.name || localMetadata?.agentflow?.id || 'Go to AgentFlow'} className={'button'} - onClick={() => window.open(`/v2/agentcanvas/${localMetadata?.agentflow?.id}`, '_blank')} + onClick={() => { + const agentflowType = localMetadata?.agentflow?.type + const agentflowId = localMetadata?.agentflow?.id + if (agentflowType === 'AGENT' || agentflowType === 'ASSISTANT') { + window.open(`/agents/${agentflowId}`, '_blank') + } else { + window.open(`/v2/agentcanvas/${agentflowId}`, '_blank') + } + }} /> )} diff --git a/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx b/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx index 60d0d732508..3a54dc4bf24 100644 --- a/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx +++ b/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx @@ -25,7 +25,7 @@ import { } from '@mui/material' import { useTheme, darken } from '@mui/material/styles' import { useSnackbar } from 'notistack' -import { IconCoins, IconCoin, IconClock, IconChevronDown, IconDownload, IconTool } from '@tabler/icons-react' +import { IconCoins, IconCoin, IconClock, IconChevronDown, IconDownload, IconTool, IconRobot } from '@tabler/icons-react' import toolSVG from '@/assets/images/tool.svg' // Project imports @@ -261,7 +261,10 @@ export const NodeExecutionDetails = ({ data, label, status, metadata, isPublic, {(() => { const nodeName = data?.name || data?.id?.split('_')[0] - const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === nodeName) + const isSmartAgent = nodeName === 'smartAgentAgentflow' + const foundIcon = isSmartAgent + ? { icon: IconRobot, color: '#9575CD' } + : AGENTFLOW_ICONS.find((icon) => icon.name === nodeName) if (foundIcon) { return ( @@ -303,7 +306,7 @@ export const NodeExecutionDetails = ({ data, label, status, metadata, isPublic, })()} - {label} + {data?.name === 'smartAgentAgentflow' ? 'Agent' : label}
{data.output && data.output.timeMetadata && data.output.timeMetadata.delta && ( diff --git a/packages/ui/src/views/agentexecutions/index.jsx b/packages/ui/src/views/agentexecutions/index.jsx index 2a6e366509b..0518d9c93f2 100644 --- a/packages/ui/src/views/agentexecutions/index.jsx +++ b/packages/ui/src/views/agentexecutions/index.jsx @@ -11,6 +11,7 @@ import { DialogContent, DialogContentText, DialogTitle, + Fade, FormControl, Grid, IconButton, @@ -235,228 +236,230 @@ const AgentExecutions = () => { {error ? ( ) : ( - - - - {/* Filter Section */} - - - - - State - - - - - onDateChange('startDate', date)} - selectsStart - startDate={filters.startDate} - className='form-control' - wrapperClassName='datePicker' - maxDate={new Date()} - customInput={ - + + + + {/* Filter Section */} + + + + + State + + + + + onDateChange('startDate', date)} + selectsStart + startDate={filters.startDate} + className='form-control' + wrapperClassName='datePicker' + maxDate={new Date()} + customInput={ + + } + /> + + + onDateChange('endDate', date)} + selectsEnd + endDate={filters.endDate} + className='form-control' + wrapperClassName='datePicker' + minDate={filters.startDate} + maxDate={new Date()} + customInput={ + + } + /> + + + handleFilterChange('agentflowName', e.target.value)} + size='small' + sx={{ + '& .MuiOutlinedInput-notchedOutline': { + borderColor: borderColor + } + }} + /> + + + handleFilterChange('sessionId', e.target.value)} + size='small' + sx={{ + '& .MuiOutlinedInput-notchedOutline': { + borderColor: borderColor + } + }} + /> + + + + + + + + + + + + + + + + - - handleFilterChange('agentflowName', e.target.value)} - size='small' - sx={{ - '& .MuiOutlinedInput-notchedOutline': { - borderColor: borderColor - } + + + {executions?.length > 0 && ( + <> + { + setOpenDrawer(true) + const executionDetails = + typeof execution.executionData === 'string' + ? JSON.parse(execution.executionData) + : execution.executionData + setSelectedExecutionData(executionDetails) + setSelectedMetadata(omit(execution, ['executionData'])) }} /> - - - handleFilterChange('sessionId', e.target.value)} - size='small' - sx={{ - '& .MuiOutlinedInput-notchedOutline': { - borderColor: borderColor - } + + {/* Pagination and Page Size Controls */} + {!isLoading && total > 0 && ( + + )} + + setOpenDrawer(false)} + onProceedSuccess={() => { + setOpenDrawer(false) + getAllExecutions.request() + }} + onUpdateSharing={() => { + getAllExecutions.request() + }} + onRefresh={(executionId) => { + getAllExecutions.request() + getExecutionByIdApi.request(executionId) }} /> - - - - - - - - - - - - - - - - - - - - {executions?.length > 0 && ( - <> - { - 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 */} - {!isLoading && total > 0 && ( - - )} - - setOpenDrawer(false)} - onProceedSuccess={() => { - setOpenDrawer(false) - getAllExecutions.request() - }} - onUpdateSharing={() => { - getAllExecutions.request() - }} - onRefresh={(executionId) => { - getAllExecutions.request() - getExecutionByIdApi.request(executionId) - }} - /> - - )} - - {/* Delete Confirmation Dialog */} - - Confirm Deletion - - - Are you sure you want to delete {selectedExecutionIds.length} execution - {selectedExecutionIds.length !== 1 ? 's' : ''}? This action cannot be undone. - - - - - - - - - {!isLoading && (!executions || executions.length === 0) && ( - - - execution_empty - -
No Executions Yet
-
- )} -
+ + )} + + {/* Delete Confirmation Dialog */} + + Confirm Deletion + + + Are you sure you want to delete {selectedExecutionIds.length} execution + {selectedExecutionIds.length !== 1 ? 's' : ''}? This action cannot be undone. + + + + + + + + + {!isLoading && (!executions || executions.length === 0) && ( + + + execution_empty + +
No Executions Yet
+
+ )} +
+ )} ) diff --git a/packages/ui/src/views/agentflows/index.jsx b/packages/ui/src/views/agentflows/index.jsx index 9ee745c16fc..0a6589fcf12 100644 --- a/packages/ui/src/views/agentflows/index.jsx +++ b/packages/ui/src/views/agentflows/index.jsx @@ -1,18 +1,30 @@ import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom' +import moment from 'moment' +import Avatar from 'boring-avatars' // material-ui -import { Box, Chip, IconButton, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material' -import { useTheme } from '@mui/material/styles' +import { + Box, + Chip, + Fade, + IconButton, + InputAdornment, + OutlinedInput, + Skeleton, + Stack, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography +} from '@mui/material' +import { useTheme, darken } from '@mui/material/styles' // project imports -import AgentsEmptySVG from '@/assets/images/agents_empty.svg' import ErrorBoundary from '@/ErrorBoundary' -import ViewHeader from '@/layout/MainLayout/ViewHeader' import { gridSpacing } from '@/store/constant' import { StyledPermissionButton } from '@/ui-component/button/RBACButtons' -import ItemCard from '@/ui-component/cards/ItemCard' import MainCard from '@/ui-component/cards/MainCard' import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' import TablePagination, { DEFAULT_ITEMS_PER_PAGE } from '@/ui-component/pagination/TablePagination' @@ -29,9 +41,9 @@ import { AGENTFLOW_ICONS, baseURL } from '@/store/constant' import { useError } from '@/store/context/ErrorContext' // icons -import { IconAlertTriangle, IconLayoutGrid, IconList, IconPlus, IconX } from '@tabler/icons-react' +import { IconAlertTriangle, IconLayoutGrid, IconList, IconSearch, IconX } from '@tabler/icons-react' -// ==============================|| AGENTS ||============================== // +// ==============================|| AGENTFLOWS ||============================== // const Agentflows = () => { const navigate = useNavigate() @@ -170,187 +182,421 @@ const Agentflows = () => { }, [getAllAgentflows.data]) return ( - - {error ? ( - - ) : ( - - - - + + {error ? ( + + ) : ( + + + {/* ==================== Hero Section ==================== */} + - - V2 - - - V1 - - - - - - - - - - - } - sx={{ borderRadius: 2, height: 40 }} - > - Add New - - - - {/* Deprecation Notice For V1 */} - {agentflowVersion === 'v1' && showDeprecationNotice && ( - - - - V1 Agentflows are deprecated. We recommend migrating to V2 for improved performance and - continued support. + + Create an agentflow + + + + Multi-agent systems and agentic workflow orchestration + + + + + Create + + {!isLoading && total === 0 && ( + navigate('/marketplaces', { state: { typeFilter: ['AgentflowV2'] } })} + sx={{ + borderRadius: '24px', + px: 3, + height: 44, + textTransform: 'none', + fontSize: '0.95rem', + fontWeight: 600, + border: `1px solid ${theme.palette.grey[900] + 40}`, + backgroundColor: 'transparent', + color: theme.palette.text.primary, + '&:hover': { + backgroundColor: theme.palette.action.hover, + borderColor: theme.palette.grey[900] + 60 + } + }} + > + View Templates + + )} + - - - - - )} - {!isLoading && total > 0 && ( - <> - {!view || view === 'card' ? ( - - {getAllAgentflows.data?.data.filter(filterFlows).map((data, index) => ( - goToCanvas(data)} - data={data} - images={images[data.id]} - icons={icons[data.id]} + + {/* ==================== Agentflows Listing Section ==================== */} + {total > 0 && ( + + + Agentflows + + + + + + } + sx={{ + width: 250, + borderRadius: 2, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.grey[900] + 25 + } + }} /> - ))} + + + + V2 + + + V1 + + + + + + + + + + + - ) : ( - )} - {/* Pagination and Page Size Controls */} - - - )} - - {!isLoading && total === 0 && ( - - - AgentsEmptySVG - -
No Agents Yet
+ + {/* Deprecation Notice For V1 */} + {agentflowVersion === 'v1' && showDeprecationNotice && ( + + + + V1 Agentflows are deprecated. We recommend migrating to V2 for improved performance + and continued support. + + + + + + )} + + {isLoading && ( + + + + + + )} + + {!isLoading && total > 0 && ( + <> + {!view || view === 'card' ? ( + + {getAllAgentflows.data?.data.filter(filterFlows).map((data, index) => { + const flowImages = images[data.id] || [] + const flowIcons = icons[data.id] || [] + const combined = [ + ...flowIcons.map((ic) => ({ + type: 'icon', + icon: ic.icon, + color: ic.color, + label: ic.name + })), + ...flowImages.map((img) => ({ type: 'image', src: img.imageSrc, label: img.label })) + ] + const visible = combined.slice(0, 4) + const remaining = combined.length - visible.length + return ( + goToCanvas(data)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.5, + p: 2, + borderRadius: 3, + border: `1px solid ${theme.palette.grey[900]}15`, + cursor: 'pointer', + backgroundColor: theme.palette.card?.main || theme.palette.background.paper, + boxShadow: '0 2px 12px rgba(0,0,0,0.08)', + transition: 'background-color 0.2s, box-shadow 0.2s', + '&:hover': { + backgroundColor: theme.palette.card?.hover || theme.palette.action.hover, + boxShadow: '0 4px 20px rgba(0,0,0,0.12)' + } + }} + > + + + + + + {data.name || 'Untitled'} + + + {moment(data.updatedDate).format('MMM D, hh:mm A')} + + + {combined.length > 0 && ( + + {visible.map((item, i) => ( + + {item.type === 'image' ? ( + + + + ) : ( + + + + )} + + ))} + {remaining > 0 && ( + + +{remaining} + + )} + + )} + + ) + })} + + ) : ( + + )} + + + )}
- )} -
- )} - -
+ + )} + + + ) } diff --git a/packages/ui/src/views/agentflowsv2/Canvas.jsx b/packages/ui/src/views/agentflowsv2/Canvas.jsx index 07bf57df51b..32177efa0d0 100644 --- a/packages/ui/src/views/agentflowsv2/Canvas.jsx +++ b/packages/ui/src/views/agentflowsv2/Canvas.jsx @@ -160,6 +160,26 @@ const AgentflowCanvas = () => { const handleLoadFlow = (file) => { try { const flowData = JSON.parse(file) + if (flowData.type && flowData.type !== 'AGENTFLOW') { + enqueueSnackbar({ + message: `Invalid file: expected AGENTFLOW type but got ${flowData.type}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + return + } + delete flowData.type const nodes = flowData.nodes || [] setNodes(nodes) diff --git a/packages/ui/src/views/agents/AgentConfigurePreview.jsx b/packages/ui/src/views/agents/AgentConfigurePreview.jsx new file mode 100644 index 00000000000..2a46f0f52a9 --- /dev/null +++ b/packages/ui/src/views/agents/AgentConfigurePreview.jsx @@ -0,0 +1,9 @@ +import CustomAssistantConfigurePreview from '@/views/assistants/custom/CustomAssistantConfigurePreview' + +// ==============================|| AGENT CONFIGURE PREVIEW ||============================== // + +const AgentConfigurePreview = () => { + return +} + +export default AgentConfigurePreview diff --git a/packages/ui/src/views/agents/index.jsx b/packages/ui/src/views/agents/index.jsx new file mode 100644 index 00000000000..337cd81c3f9 --- /dev/null +++ b/packages/ui/src/views/agents/index.jsx @@ -0,0 +1,644 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import moment from 'moment' +import Avatar from 'boring-avatars' + +// material-ui +import { + Box, + Chip, + Fade, + IconButton, + InputAdornment, + OutlinedInput, + Paper, + Skeleton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography +} from '@mui/material' +import { useTheme, styled, darken } from '@mui/material/styles' +import { tableCellClasses } from '@mui/material/TableCell' +import { useSelector } from 'react-redux' + +// project imports +import MainCard from '@/ui-component/cards/MainCard' +import { baseURL, gridSpacing } from '@/store/constant' +import ErrorBoundary from '@/ErrorBoundary' +import { StyledPermissionButton } from '@/ui-component/button/RBACButtons' +import AgentListMenu from '@/ui-component/button/AgentListMenu' +import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' +import TablePagination, { DEFAULT_ITEMS_PER_PAGE } from '@/ui-component/pagination/TablePagination' + +// API +import chatflowsApi from '@/api/chatflows' + +// Hooks +import useApi from '@/hooks/useApi' + +// icons +import { IconArrowUp, IconLayoutGrid, IconList, IconSearch } from '@tabler/icons-react' + +// ==============================|| CONSTANTS ||============================== // + +const SUGGESTION_CHIPS = ['Trip planner', 'Image generator', 'Code debugger', 'Research assistant', 'Decision helper'] + +// ==============================|| HELPERS ||============================== // + +const parseAgentFromFlowData = (agent) => { + try { + if (!agent.flowData) return { name: agent.name, instruction: '', modelName: '' } + const flowData = JSON.parse(agent.flowData) + const agentNode = flowData.nodes?.find((n) => n.data?.name === 'agentAgentflow') + if (agentNode) { + const inputs = agentNode.data?.inputs || {} + const instruction = inputs.agentMessages?.[0]?.content || '' + const modelName = inputs.agentModel || '' + return { name: agent.name, instruction, modelName } + } + const toolAgentNode = flowData.nodes?.find((n) => n.data?.name === 'toolAgent') + if (toolAgentNode) { + const instruction = toolAgentNode.data?.inputs?.systemMessage || '' + const chatModelNode = flowData.nodes?.find((n) => n.data?.category === 'Chat Models') + const modelName = chatModelNode?.data?.name || '' + return { name: agent.name, instruction, modelName } + } + return { name: agent.name, instruction: '', modelName: '' } + } catch { + return { name: agent.name || 'Untitled', instruction: '', modelName: '' } + } +} + +// ==============================|| STYLED COMPONENTS ||============================== // + +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)(() => ({ + '&:last-child td, &:last-child th': { + border: 0 + } +})) + +// ==============================|| AGENTS ||============================== // + +const Agents = () => { + const navigate = useNavigate() + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + const getAllAgentsApi = useApi(chatflowsApi.getAllAgentflows) + + const [isLoading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [agents, setAgents] = useState([]) + const [total, setTotal] = useState(0) + + const [search, setSearch] = useState('') + const [generateInput, setGenerateInput] = useState('') + const [view, setView] = useState(localStorage.getItem('agentDisplayStyle') || 'card') + const [order, setOrder] = useState(localStorage.getItem('agent_order') || 'desc') + const [orderBy, setOrderBy] = useState(localStorage.getItem('agent_orderBy') || 'updatedDate') + const [currentPage, setCurrentPage] = useState(1) + const [pageLimit, setPageLimit] = useState(() => Number(localStorage.getItem('agentPageSize') || DEFAULT_ITEMS_PER_PAGE)) + + const onSearchChange = (event) => { + setSearch(event.target.value) + } + + const handleChange = (event, nextView) => { + if (nextView === null) return + localStorage.setItem('agentDisplayStyle', nextView) + setView(nextView) + } + + const handleRequestSort = (property) => { + const isAsc = orderBy === property && order === 'asc' + const newOrder = isAsc ? 'desc' : 'asc' + setOrder(newOrder) + setOrderBy(property) + localStorage.setItem('agent_order', newOrder) + localStorage.setItem('agent_orderBy', property) + } + + const addNew = () => { + navigate('/agents/new') + } + + const handleGenerate = (taskText) => { + const task = taskText || generateInput + if (!task.trim()) return + navigate('/agents/new', { state: { generateTask: task.trim() } }) + } + + const handleGenerateKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleGenerate() + } + } + + function filterAgents(agent) { + if (!search) return true + return agent.name && agent.name.toLowerCase().indexOf(search.toLowerCase()) > -1 + } + + const getImages = (agent) => { + const images = [] + const parsed = parseAgentFromFlowData(agent) + if (parsed.modelName) { + images.push({ imageSrc: `${baseURL}/api/v1/node-icon/${parsed.modelName}` }) + } + return images + } + + const getInstruction = (agent) => { + return parseAgentFromFlowData(agent).instruction + } + + const getModelName = (agent) => { + return parseAgentFromFlowData(agent).modelName + } + + const getSortedData = (data) => { + if (!data) return [] + return [...data].filter(filterAgents).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 + }) + } + + const refreshAgents = (page, limit) => { + const params = { page: page || currentPage, limit: limit || pageLimit } + getAllAgentsApi.request('AGENT', params) + } + + const onPageChange = (page, limit) => { + setCurrentPage(page) + setPageLimit(limit) + localStorage.setItem('agentPageSize', limit) + refreshAgents(page, limit) + } + + useEffect(() => { + refreshAgents(currentPage, pageLimit) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + setLoading(getAllAgentsApi.loading) + }, [getAllAgentsApi.loading]) + + useEffect(() => { + if (getAllAgentsApi.error) setError(getAllAgentsApi.error) + }, [getAllAgentsApi.error]) + + useEffect(() => { + if (getAllAgentsApi.data) { + const agentList = getAllAgentsApi.data?.data || getAllAgentsApi.data || [] + setAgents(agentList) + setTotal(getAllAgentsApi.data?.total ?? agentList.length) + } + }, [getAllAgentsApi.data]) + + return ( + <> + + {error ? ( + + ) : ( + + + {/* ==================== Hero Section ==================== */} + + + Create an agent + + + + + Create Manually + + + setGenerateInput(e.target.value)} + onKeyDown={handleGenerateKeyDown} + endAdornment={ + + handleGenerate()} + disabled={!generateInput.trim()} + sx={{ + backgroundColor: generateInput.trim() + ? customization.isDarkMode + ? theme.palette.common.white + : theme.palette.common.black + : 'transparent', + color: generateInput.trim() + ? customization.isDarkMode + ? theme.palette.common.black + : theme.palette.common.white + : theme.palette.text.secondary, + borderRadius: '50%', + width: 28, + height: 28, + '&:hover': { + backgroundColor: generateInput.trim() + ? customization.isDarkMode + ? theme.palette.grey[200] + : theme.palette.grey[800] + : theme.palette.action.hover + } + }} + > + + + + } + sx={{ + borderRadius: '24px', + height: 44, + width: 200, + overflow: 'hidden', + '& .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.grey[900] + 25, + borderRadius: '24px' + } + }} + /> + + + + {SUGGESTION_CHIPS.map((label) => ( + handleGenerate(label)} + sx={{ + borderRadius: '16px', + cursor: 'pointer', + fontWeight: 500, + borderColor: theme.palette.grey[900] + 25, + color: theme.palette.text.primary, + '&:hover': { + backgroundColor: customization.isDarkMode + ? 'rgba(255,255,255,0.08)' + : 'rgba(0,0,0,0.04)' + } + }} + /> + ))} + + + + {/* ==================== Agents Listing Section ==================== */} + {total > 0 && ( + + + Agents + + + + + + } + sx={{ + width: 250, + borderRadius: 2, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.grey[900] + 25 + } + }} + /> + + + + + + + + + + + )} + + {isLoading && ( + + + + + + )} + + {!isLoading && total > 0 && ( + <> + {!view || view === 'card' ? ( + + {getSortedData(agents).map((agent, index) => ( + navigate(`/agents/${agent.id}`)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.5, + p: 2, + borderRadius: 3, + border: `1px solid ${theme.palette.grey[900]}15`, + cursor: 'pointer', + backgroundColor: theme.palette.card?.main || theme.palette.background.paper, + boxShadow: '0 2px 12px rgba(0,0,0,0.08)', + transition: 'background-color 0.2s, box-shadow 0.2s', + '&:hover': { + backgroundColor: theme.palette.card?.hover || theme.palette.action.hover, + boxShadow: '0 4px 20px rgba(0,0,0,0.12)' + } + }} + > + + + + + + {agent.name || 'Untitled'} + + + {moment(agent.updatedDate).format('MMM D, hh:mm A')} + + + + ))} + + ) : ( + + + + + + handleRequestSort('name')} + > + Name + + + Model + Instruction + + handleRequestSort('updatedDate')} + > + Last Modified + + + Actions + + + + {getSortedData(agents).map((agent, index) => { + const images = getImages(agent) + return ( + navigate(`/agents/${agent.id}`)} + > + + + + {agent.name || 'Untitled'} + + + + + {images.length > 0 && ( + + + + + + )} + + + + {getInstruction(agent) || ''} + + + + + {moment(agent.updatedDate).format('MMMM D, YYYY')} + + + e.stopPropagation()}> + + + + ) + })} + +
+
+ )} + + + )} +
+
+ )} +
+ + + ) +} + +export default Agents diff --git a/packages/ui/src/views/apikey/index.jsx b/packages/ui/src/views/apikey/index.jsx index 190431876f5..32ab7f71a94 100644 --- a/packages/ui/src/views/apikey/index.jsx +++ b/packages/ui/src/views/apikey/index.jsx @@ -10,6 +10,7 @@ import { Button, Chip, Collapse, + Fade, IconButton, Paper, Popover, @@ -385,148 +386,150 @@ const APIKey = () => { {error ? ( ) : ( - - - } - id='btn_createApiKey' + + + - Create Key - - - {!isLoading && apiKeys?.length <= 0 ? ( - - - APIEmptySVG - -
No API Keys Yet
-
- ) : ( - <> - } + id='btn_createApiKey' > - - - - Key Name - API Key - Permissions - Usage - Updated - - - - - - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {apiKeys?.filter(filterKeys).map((key, index) => ( - { - navigator.clipboard.writeText(key.apiKey) - setAnchorEl(event.currentTarget) - setTimeout(() => { - handleClosePopOver() - }, 1500) - }} - onShowAPIClick={() => onShowApiKeyClick(key.apiKey)} - open={openPopOver} - anchorEl={anchorEl} - onClose={handleClosePopOver} - theme={theme} - onEditClick={() => edit(key)} - onDeleteClick={() => deleteKey(key)} - /> - ))} - - )} - -
-
- {/* Pagination and Page Size Controls */} - - - )} -
+ Create Key + + + {!isLoading && apiKeys?.length <= 0 ? ( + + + APIEmptySVG + +
No API Keys Yet
+
+ ) : ( + <> + + + + + Key Name + API Key + Permissions + Usage + Updated + + + + + + + + + + {isLoading ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> + {apiKeys?.filter(filterKeys).map((key, index) => ( + { + navigator.clipboard.writeText(key.apiKey) + setAnchorEl(event.currentTarget) + setTimeout(() => { + handleClosePopOver() + }, 1500) + }} + onShowAPIClick={() => onShowApiKeyClick(key.apiKey)} + open={openPopOver} + anchorEl={anchorEl} + onClose={handleClosePopOver} + theme={theme} + onEditClick={() => edit(key)} + onDeleteClick={() => deleteKey(key)} + /> + ))} + + )} + +
+
+ {/* Pagination and Page Size Controls */} + + + )} + + )} const createResp = await assistantsApi.createNewAssistant(obj) if (createResp.data) { enqueueSnackbar({ - message: 'New Custom Assistant created.', + message: 'New Agent created.', options: { key: new Date().getTime() + Math.random(), variant: 'success', @@ -73,7 +73,7 @@ const AddCustomAssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => } } catch (err) { enqueueSnackbar({ - message: `Failed to add new Custom Assistant: ${ + message: `Failed to add new Agent: ${ typeof err.response.data === 'object' ? err.response.data.message : err.response.data }`, options: { @@ -102,7 +102,7 @@ const AddCustomAssistantDialog = ({ show, dialogProps, onCancel, onConfirm }) => >
- + {dialogProps.title}
diff --git a/packages/ui/src/views/assistants/custom/CustomAssistantConfigurePreview.jsx b/packages/ui/src/views/assistants/custom/CustomAssistantConfigurePreview.jsx index 057241529ac..99a98d4014c 100644 --- a/packages/ui/src/views/assistants/custom/CustomAssistantConfigurePreview.jsx +++ b/packages/ui/src/views/assistants/custom/CustomAssistantConfigurePreview.jsx @@ -1,7 +1,7 @@ import { cloneDeep, set } from 'lodash' import { memo, useEffect, useState, useRef } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useNavigate, useParams } from 'react-router-dom' +import { useNavigate, useParams, useLocation } from 'react-router-dom' import { FullPageChat } from 'flowise-embed-react' import PropTypes from 'prop-types' @@ -10,7 +10,32 @@ import useApi from '@/hooks/useApi' import useConfirm from '@/hooks/useConfirm' // Material-UI -import { IconButton, Avatar, ButtonBase, Toolbar, Box, Button, Grid, OutlinedInput, Stack, Typography } from '@mui/material' +import { + IconButton, + Avatar, + ButtonBase, + Toolbar, + Box, + Button, + Grid, + OutlinedInput, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, + Checkbox, + FormControlLabel, + FormGroup, + Chip, + Accordion, + AccordionSummary, + AccordionDetails, + Dialog, + DialogTitle, + DialogContent, + DialogActions +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { useTheme } from '@mui/material/styles' import { IconCode, @@ -20,7 +45,11 @@ import { IconX, IconTrash, IconWand, - IconArrowsMaximize + IconArrowsMaximize, + IconEdit, + IconCheck, + IconUpload, + IconCopy } from '@tabler/icons-react' // Project import @@ -29,6 +58,7 @@ import { BackdropLoader } from '@/ui-component/loading/BackdropLoader' import DocStoreInputHandler from '@/views/docstore/DocStoreInputHandler' import { Dropdown } from '@/ui-component/dropdown/Dropdown' import { StyledFab } from '@/ui-component/button/StyledFab' +import { StyledButton } from '@/ui-component/button/StyledButton' import ErrorBoundary from '@/ErrorBoundary' import { TooltipWithParser } from '@/ui-component/tooltip/TooltipWithParser' import { MultiDropdown } from '@/ui-component/dropdown/MultiDropdown' @@ -38,25 +68,28 @@ import ChatflowConfigurationDialog from '@/ui-component/dialog/ChatflowConfigura import ViewLeadsDialog from '@/ui-component/dialog/ViewLeadsDialog' import Settings from '@/views/settings' import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' +import { CodeEditor } from '@/ui-component/editor/CodeEditor' import PromptGeneratorDialog from '@/ui-component/dialog/PromptGeneratorDialog' import { Available } from '@/ui-component/rbac/available' import ExpandTextDialog from '@/ui-component/dialog/ExpandTextDialog' +import ExportAsTemplateDialog from '@/ui-component/dialog/ExportAsTemplateDialog' import { SwitchInput } from '@/ui-component/switch/Switch' +import DescribeMode from './DescribeMode' // API import assistantsApi from '@/api/assistants' import chatflowsApi from '@/api/chatflows' import nodesApi from '@/api/nodes' import documentstoreApi from '@/api/documentstore' +import userApi from '@/api/user' // Const -import { baseURL } from '@/store/constant' +import { baseURL, uiBaseURL } from '@/store/constant' import { SET_CHATFLOW, closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions' // Utils -import { initNode, showHideInputParams } from '@/utils/genericHelper' +import { initNode, showHideInputParams, generateExportFlowData } from '@/utils/genericHelper' import useNotifier from '@/utils/useNotifier' -import { toolAgentFlow } from './toolAgentFlow' // ===========================|| CustomAssistantConfigurePreview ||=========================== // @@ -75,33 +108,92 @@ MemoizedFullPageChat.propTypes = { chatflow: PropTypes.object } -const CustomAssistantConfigurePreview = () => { +// Helper to build the built-in tools map from agentNodeDef — keyed by the model name in the `show` condition +const getBuiltInToolsMap = (agentNodeDef) => { + if (!agentNodeDef?.inputParams) return {} + const map = {} + const builtInParams = agentNodeDef.inputParams.filter((p) => p.name?.startsWith('agentToolsBuiltIn') && p.type === 'multiOptions') + for (const param of builtInParams) { + // The `show` condition maps to the model component name, e.g. { agentModel: 'chatOpenAI' } + const modelName = param.show?.agentModel + if (modelName) { + map[modelName] = { + paramName: param.name, + options: param.options || [] + } + } + } + return map +} + +// Helper to extract structured output type options from the agentStructuredOutput array definition +const getStructuredOutputTypeOptions = (agentNodeDef) => { + if (!agentNodeDef?.inputParams) return [] + const param = agentNodeDef.inputParams.find((p) => p.name === 'agentStructuredOutput') + if (!param?.array) return [] + const typeField = param.array.find((a) => a.name === 'type') + return typeField?.options || [] +} + +const CustomAssistantConfigurePreview = ({ chatflowType: _chatflowType = 'ASSISTANT' }) => { const navigate = useNavigate() const theme = useTheme() const settingsRef = useRef() + const loadAgentInputRef = useRef() const canvas = useSelector((state) => state.canvas) const customization = useSelector((state) => state.customization) + const currentUser = useSelector((state) => state.auth.user) - const getSpecificAssistantApi = useApi(assistantsApi.getSpecificAssistant) const getChatModelsApi = useApi(assistantsApi.getChatModels) const getDocStoresApi = useApi(assistantsApi.getDocStores) const getToolsApi = useApi(assistantsApi.getTools) const getSpecificChatflowApi = useApi(chatflowsApi.getSpecificChatflow) + const getOrganizationApi = useApi(userApi.getOrganizationById) - const { id: customAssistantId } = useParams() + const { id: routeId } = useParams() + const location = useLocation() + const isNewAgent = !routeId || routeId === 'new' + const isTemplatePreview = !!location.state?.templateData + const templateData = location.state?.templateData + + // chatflowId is always from the route param (chatflow table ID) + const [chatflowId, setChatflowId] = useState(isNewAgent ? null : routeId) const [chatModelsComponents, setChatModelsComponents] = useState([]) const [chatModelsOptions, setChatModelsOptions] = useState([]) const [selectedChatModel, setSelectedChatModel] = useState({}) - const [selectedCustomAssistant, setSelectedCustomAssistant] = useState({}) + const [modelConfigDialogOpen, setModelConfigDialogOpen] = useState(false) + const previousChatModelRef = useRef(null) + const [agentName, setAgentName] = useState('New Agent') + const [creationMode, setCreationMode] = useState(location.state?.generateTask ? 'describe' : 'manual') + const [defaultCheckComplete, setDefaultCheckComplete] = useState(false) + const [modelConfirmed, setModelConfirmed] = useState(false) const [customAssistantInstruction, setCustomAssistantInstruction] = useState('You are helpful assistant') - const [customAssistantFlowId, setCustomAssistantFlowId] = useState() const [documentStoreOptions, setDocumentStoreOptions] = useState([]) const [selectedDocumentStores, setSelectedDocumentStores] = useState([]) const [toolComponents, setToolComponents] = useState([]) const [toolOptions, setToolOptions] = useState([]) const [selectedTools, setSelectedTools] = useState([]) + const [vectorStoreOptions, setVectorStoreOptions] = useState([]) + const [vectorStoreComponents, setVectorStoreComponents] = useState([]) + const [embeddingModelOptions, setEmbeddingModelOptions] = useState([]) + const [embeddingModelComponents, setEmbeddingModelComponents] = useState([]) + const [knowledgeVSEmbeddings, setKnowledgeVSEmbeddings] = useState([]) + + // Built-in tools — dynamic map keyed by param name (e.g. 'agentToolsBuiltInOpenAI': ['web_search_preview']) + const [builtInTools, setBuiltInTools] = useState({}) + + const [enableMemory, setEnableMemory] = useState(true) + const [memoryType, setMemoryType] = useState('allMessages') + const [memoryWindowSize, setMemoryWindowSize] = useState(20) + const [memoryMaxTokenLimit, setMemoryMaxTokenLimit] = useState(2000) + + const [structuredOutput, setStructuredOutput] = useState([]) + + const [agentNodeDef, setAgentNodeDef] = useState(null) + const [startNodeDef, setStartNodeDef] = useState(null) + const [apiDialogOpen, setAPIDialogOpen] = useState(false) const [apiDialogProps, setAPIDialogProps] = useState({}) const [viewMessagesDialogOpen, setViewMessagesDialogOpen] = useState(false) @@ -111,6 +203,8 @@ const CustomAssistantConfigurePreview = () => { const [chatflowConfigurationDialogOpen, setChatflowConfigurationDialogOpen] = useState(false) const [chatflowConfigurationDialogProps, setChatflowConfigurationDialogProps] = useState({}) const [isSettingsOpen, setSettingsOpen] = useState(false) + const [exportAsTemplateDialogOpen, setExportAsTemplateDialogOpen] = useState(false) + const [exportAsTemplateDialogProps, setExportAsTemplateDialogProps] = useState({}) const [assistantPromptGeneratorDialogOpen, setAssistantPromptGeneratorDialogOpen] = useState(false) const [assistantPromptGeneratorDialogProps, setAssistantPromptGeneratorDialogProps] = useState({}) const [showExpandDialog, setShowExpandDialog] = useState(false) @@ -118,6 +212,22 @@ const CustomAssistantConfigurePreview = () => { const [loading, setLoading] = useState(false) const [loadingAssistant, setLoadingAssistant] = useState(true) + const [isEditingName, setIsEditingName] = useState(false) + const [editingNameValue, setEditingNameValue] = useState('') + + const saveAgentName = async (newName) => { + if (!newName || !newName.trim()) return + setAgentName(newName) + setIsEditingName(false) + // Persist to database immediately if the agent already exists + if (!isNewAgent && chatflowId) { + try { + await chatflowsApi.updateChatflow(chatflowId, { name: newName }) + } catch (e) { + console.error('Failed to save agent name', e) + } + } + } const [error, setError] = useState(null) const dispatch = useDispatch() @@ -150,6 +260,52 @@ const CustomAssistantConfigurePreview = () => { }) } + const handleVSDataChange = + (itemIndex) => + ({ inputParam, newValue }) => { + setKnowledgeVSEmbeddings((prev) => { + const updated = [...prev] + const item = { ...updated[itemIndex] } + if (item.vectorStoreNode) { + item.vectorStoreNode = { ...item.vectorStoreNode } + item.vectorStoreNode.inputs = { ...item.vectorStoreNode.inputs, [inputParam.name]: newValue } + item.vectorStoreNode.inputParams = showHideInputParams(item.vectorStoreNode) + } + updated[itemIndex] = item + return updated + }) + } + + const handleEmbeddingDataChange = + (itemIndex) => + ({ inputParam, newValue }) => { + setKnowledgeVSEmbeddings((prev) => { + const updated = [...prev] + const item = { ...updated[itemIndex] } + if (item.embeddingNode) { + item.embeddingNode = { ...item.embeddingNode } + item.embeddingNode.inputs = { ...item.embeddingNode.inputs, [inputParam.name]: newValue } + item.embeddingNode.inputParams = showHideInputParams(item.embeddingNode) + } + updated[itemIndex] = item + return updated + }) + } + + const initVectorStoreNode = (componentName, index) => { + const foundComponent = vectorStoreComponents.find((c) => c.name === componentName) + if (!foundComponent) return null + const clonedComponent = cloneDeep(foundComponent) + return initNode(clonedComponent, `${componentName}_vs_${index}`) + } + + const initEmbeddingNode = (componentName, index) => { + const foundComponent = embeddingModelComponents.find((c) => c.name === componentName) + if (!foundComponent) return null + const clonedComponent = cloneDeep(foundComponent) + return initNode(clonedComponent, `${componentName}_emb_${index}`) + } + const displayWarning = () => { enqueueSnackbar({ message: 'Please fill in all mandatory fields.', @@ -208,11 +364,15 @@ const CustomAssistantConfigurePreview = () => { const checkMandatoryFields = () => { let canSubmit = true + if (!agentName || !agentName.trim()) { + canSubmit = false + } + if (!selectedChatModel || !selectedChatModel.name) { canSubmit = false } - canSubmit = checkInputParamsMandatory() + if (canSubmit) canSubmit = checkInputParamsMandatory() // check if any of the description is empty if (selectedDocumentStores.length > 0) { @@ -230,267 +390,163 @@ const CustomAssistantConfigurePreview = () => { return canSubmit } - const onSaveAndProcess = async () => { - if (checkMandatoryFields()) { - setLoading(true) - const flowData = await prepareConfig() - if (!flowData) return - const saveObj = { - id: customAssistantId, - name: selectedCustomAssistant.name, - flowData: JSON.stringify(flowData), - type: 'ASSISTANT' - } - try { - let saveResp - if (!customAssistantFlowId) { - saveResp = await chatflowsApi.createNewChatflow(saveObj) - } else { - saveResp = await chatflowsApi.updateChatflow(customAssistantFlowId, saveObj) - } - - if (saveResp.data) { - setCustomAssistantFlowId(saveResp.data.id) - dispatch({ type: SET_CHATFLOW, chatflow: saveResp.data }) - - const assistantDetails = { - ...selectedCustomAssistant, - chatModel: selectedChatModel, - instruction: customAssistantInstruction, - flowId: saveResp.data.id, - documentStores: selectedDocumentStores, - tools: selectedTools - } - - const saveAssistantResp = await assistantsApi.updateAssistant(customAssistantId, { - details: JSON.stringify(assistantDetails) - }) + // ==============================|| Build Config ||============================== // - if (saveAssistantResp.data) { - setLoading(false) - enqueueSnackbar({ - message: 'Assistant saved successfully', - options: { - key: new Date().getTime() + Math.random(), - variant: 'success', - action: (key) => ( - - ) - } - }) - } - } - } catch (error) { - setLoading(false) - enqueueSnackbar({ - message: `Failed to save assistant: ${ - typeof error.response.data === 'object' ? error.response.data.message : error.response.data - }`, - options: { - key: new Date().getTime() + Math.random(), - variant: 'error', - action: (key) => ( - - ) - } - }) - } + const buildModelConfig = () => { + const config = { + credential: selectedChatModel.credential || '', + agentModel: selectedChatModel.name + } + // Copy all model input params + if (selectedChatModel.inputs) { + Object.keys(selectedChatModel.inputs).forEach((key) => { + config[key] = selectedChatModel.inputs[key] + }) } + return config } - const addTools = async (toolAgentId) => { - const nodes = [] - const edges = [] - - for (let i = 0; i < selectedTools.length; i++) { - try { - const tool = selectedTools[i] - const toolId = `${tool.name}_${i}` - const toolNodeData = cloneDeep(tool) - set(toolNodeData, 'inputs', tool.inputs) - - const toolNodeObj = { - id: toolId, - data: { - ...toolNodeData, - id: toolId - } + const buildToolsArray = () => { + return selectedTools + .filter((tool) => tool && Object.keys(tool).length > 0) + .map((tool) => { + const toolConfig = { + credential: tool.credential || '', + agentSelectedTool: tool.name } - nodes.push(toolNodeObj) - - const toolEdge = { - source: toolId, - sourceHandle: `${toolId}-output-${tool.name}-Tool`, - target: toolAgentId, - targetHandle: `${toolAgentId}-input-tools-Tool`, - type: 'buttonedge', - id: `${toolId}-${toolId}-output-${tool.name}-Tool-${toolAgentId}-${toolAgentId}-input-tools-Tool` + if (tool.inputs) { + Object.keys(tool.inputs).forEach((key) => { + toolConfig[key] = tool.inputs[key] + }) } - edges.push(toolEdge) - } catch (error) { - console.error('Error adding tool', error) - } - } - - return { nodes, edges } + return { + agentSelectedTool: tool.name, + agentSelectedToolConfig: toolConfig, + agentSelectedToolRequiresHumanInput: tool.requireHumanInput ?? false + } + }) } - const addDocStore = async (toolAgentId) => { - const docStoreVSNode = await nodesApi.getSpecificNode('documentStoreVS') - const retrieverToolNode = await nodesApi.getSpecificNode('retrieverTool') - - const nodes = [] - const edges = [] - - for (let i = 0; i < selectedDocumentStores.length; i++) { - try { - const docStoreVSId = `documentStoreVS_${i}` - const retrieverToolId = `retrieverTool_${i}` - - const docStoreVSNodeData = cloneDeep(initNode(docStoreVSNode.data, docStoreVSId)) - const retrieverToolNodeData = cloneDeep(initNode(retrieverToolNode.data, retrieverToolId)) - - set(docStoreVSNodeData, 'inputs.selectedStore', selectedDocumentStores[i].id) - set(docStoreVSNodeData, 'outputs.output', 'retriever') - - const docStoreOption = documentStoreOptions.find((ds) => ds.name === selectedDocumentStores[i].id) - // convert to small case and replace space with underscore - const name = (docStoreOption?.label || '') - .toLowerCase() - .replace(/ /g, '_') - .replace(/[^a-z0-9_-]/g, '') - const desc = selectedDocumentStores[i].description || docStoreOption?.description || '' - - set(retrieverToolNodeData, 'inputs', { - name, - description: desc, - retriever: `{{${docStoreVSId}.data.instance}}`, - returnSourceDocuments: selectedDocumentStores[i].returnSourceDocuments ?? false - }) + const buildDocStoresArray = () => { + return selectedDocumentStores.map((ds) => ({ + documentStore: `${ds.id}:${ds.name}`, + docStoreDescription: ds.description || '', + returnSourceDocuments: ds.returnSourceDocuments ?? false + })) + } - const docStoreVS = { - id: docStoreVSId, - data: { - ...docStoreVSNodeData, - id: docStoreVSId + const buildVSEmbeddingsArray = () => { + return knowledgeVSEmbeddings + .filter((item) => item.vectorStore && item.embeddingModel) + .map((item) => { + const vsConfig = { credential: '', agentSelectedTool: item.vectorStore } + if (item.vectorStoreNode) { + vsConfig.credential = item.vectorStoreNode.credential || '' + if (item.vectorStoreNode.inputs) { + Object.keys(item.vectorStoreNode.inputs).forEach((key) => { + vsConfig[key] = item.vectorStoreNode.inputs[key] + }) } } - nodes.push(docStoreVS) - - const retrieverTool = { - id: retrieverToolId, - data: { - ...retrieverToolNodeData, - id: retrieverToolId + const embConfig = { credential: '', agentSelectedTool: item.embeddingModel } + if (item.embeddingNode) { + embConfig.credential = item.embeddingNode.credential || '' + if (item.embeddingNode.inputs) { + Object.keys(item.embeddingNode.inputs).forEach((key) => { + embConfig[key] = item.embeddingNode.inputs[key] + }) } } - nodes.push(retrieverTool) - - const docStoreVSEdge = { - source: docStoreVSId, - sourceHandle: `${docStoreVSId}-output-retriever-BaseRetriever`, - target: retrieverToolId, - targetHandle: `${retrieverToolId}-input-retriever-BaseRetriever`, - type: 'buttonedge', - id: `${docStoreVSId}-${docStoreVSId}-output-retriever-BaseRetriever-${retrieverToolId}-${retrieverToolId}-input-retriever-BaseRetriever` - } - edges.push(docStoreVSEdge) - - const retrieverToolEdge = { - source: retrieverToolId, - sourceHandle: `${retrieverToolId}-output-retrieverTool-RetrieverTool|DynamicTool|Tool|StructuredTool|Runnable`, - target: toolAgentId, - targetHandle: `${toolAgentId}-input-tools-Tool`, - type: 'buttonedge', - id: `${retrieverToolId}-${retrieverToolId}-output-retrieverTool-RetrieverTool|DynamicTool|Tool|StructuredTool|Runnable-${toolAgentId}-${toolAgentId}-input-tools-Tool` + return { + vectorStore: item.vectorStore, + vectorStoreConfig: vsConfig, + embeddingModel: item.embeddingModel, + embeddingModelConfig: embConfig, + knowledgeName: item.knowledgeName || '', + knowledgeDescription: item.knowledgeDescription || '', + returnSourceDocuments: item.returnSourceDocuments ?? false } - edges.push(retrieverToolEdge) - } catch (error) { - console.error('Error adding doc store', error) - } - } - - return { nodes, edges } + }) } const prepareConfig = async () => { try { - const config = {} - - const nodes = toolAgentFlow.nodes - const edges = toolAgentFlow.edges - const chatModelId = `${selectedChatModel.name}_0` - const existingChatModelId = nodes.find((node) => node.data.category === 'Chat Models')?.id - - // Replace Chat Model - let filteredNodes = nodes.filter((node) => node.data.category !== 'Chat Models') - const toBeReplaceNode = { - id: chatModelId, - data: { - ...selectedChatModel, - id: chatModelId - } - } - filteredNodes.push(toBeReplaceNode) - - // Replace Tool Agent inputs - const toolAgentNode = filteredNodes.find((node) => node.data.name === 'toolAgent') - const toolAgentId = toolAgentNode.id - set(toolAgentNode.data.inputs, 'model', `{{${chatModelId}}}`) - set(toolAgentNode.data.inputs, 'systemMessage', `${customAssistantInstruction}`) - - const agentTools = [] - if (selectedDocumentStores.length > 0) { - const retrieverTools = selectedDocumentStores.map((_, index) => `{{retrieverTool_${index}}}`) - agentTools.push(...retrieverTools) - } - if (selectedTools.length > 0) { - const tools = selectedTools.map((_, index) => `{{${selectedTools[index].id}}}`) - agentTools.push(...tools) + if (!startNodeDef || !agentNodeDef) { + throw new Error('Node definitions not loaded yet') } - set(toolAgentNode.data.inputs, 'tools', agentTools) - filteredNodes = filteredNodes.map((node) => (node.id === toolAgentNode.id ? toolAgentNode : node)) + const startNode = cloneDeep(startNodeDef) + const agentNode = cloneDeep(agentNodeDef) - // Go through each edge and loop through each key. Check if the string value of each key includes/contains existingChatModelId, if yes replace with chatModelId - let filteredEdges = edges.map((edge) => { - const newEdge = { ...edge } - Object.keys(newEdge).forEach((key) => { - if (newEdge[key].includes(existingChatModelId)) { - newEdge[key] = newEdge[key].replaceAll(existingChatModelId, chatModelId) - } - }) - return newEdge - }) + // Set agent node inputs + set(agentNode, 'inputs.agentModel', selectedChatModel.name) + set(agentNode, 'inputs.agentModelConfig', buildModelConfig()) + set(agentNode, 'inputs.agentMessages', [{ role: 'system', content: customAssistantInstruction }]) - // Add Doc Store - if (selectedDocumentStores.length > 0) { - const { nodes: newNodes, edges: newEdges } = await addDocStore(toolAgentId) - filteredNodes = [...filteredNodes, ...newNodes] - filteredEdges = [...filteredEdges, ...newEdges] + // Built-in tools — save all dynamic param entries + const builtInToolsMap = getBuiltInToolsMap(agentNodeDef) + for (const modelKey of Object.keys(builtInToolsMap)) { + const paramName = builtInToolsMap[modelKey].paramName + const tools = builtInTools[paramName] || [] + set(agentNode, `inputs.${paramName}`, tools.length > 0 ? JSON.stringify(tools) : '') } - // Add Tools - if (selectedTools.length > 0) { - const { nodes: newNodes, edges: newEdges } = await addTools(toolAgentId) - filteredNodes = [...filteredNodes, ...newNodes] - filteredEdges = [...filteredEdges, ...newEdges] + // Custom tools + set(agentNode, 'inputs.agentTools', buildToolsArray()) + + // Knowledge + set(agentNode, 'inputs.agentKnowledgeDocumentStores', buildDocStoresArray()) + set(agentNode, 'inputs.agentKnowledgeVSEmbeddings', buildVSEmbeddingsArray()) + + // Memory + set(agentNode, 'inputs.agentEnableMemory', enableMemory) + set(agentNode, 'inputs.agentMemoryType', memoryType) + set(agentNode, 'inputs.agentMemoryWindowSize', String(memoryWindowSize)) + set(agentNode, 'inputs.agentMemoryMaxTokenLimit', String(memoryMaxTokenLimit)) + + // Structured output + set(agentNode, 'inputs.agentStructuredOutput', structuredOutput) + + // Return response as user message + set(agentNode, 'inputs.agentReturnResponseAs', 'userMessage') + + const config = { + nodes: [ + { + id: startNode.id, + type: 'agentFlow', + position: { x: 70.5, y: 107 }, + data: startNode, + width: 103, + height: 66 + }, + { + id: agentNode.id, + type: 'agentFlow', + position: { x: 231, y: 105 }, + data: agentNode, + width: 175, + height: 72 + } + ], + edges: [ + { + source: 'startAgentflow_0', + sourceHandle: 'startAgentflow_0-output-startAgentflow', + target: 'smartAgentAgentflow_0', + targetHandle: 'smartAgentAgentflow_0', + data: { sourceColor: '#7EE787', targetColor: '#4DD0E1', isHumanInput: false }, + type: 'agentFlow', + id: 'startAgentflow_0-startAgentflow_0-output-startAgentflow-smartAgentAgentflow_0-smartAgentAgentflow_0' + } + ] } - config.nodes = filteredNodes - config.edges = filteredEdges - return config } catch (error) { console.error('Error preparing config', error) enqueueSnackbar({ - message: `Failed to save assistant: ${ - typeof error.response.data === 'object' ? error.response.data.message : error.response.data - }`, + message: `Failed to save agent: ${typeof error === 'string' ? error : error.message || 'Unknown error'}`, options: { key: new Date().getTime() + Math.random(), variant: 'error', @@ -505,10 +561,79 @@ const CustomAssistantConfigurePreview = () => { } } + // ==============================|| Save & Process ||============================== // + + const onSaveAndProcess = async () => { + if (checkMandatoryFields()) { + setLoading(true) + const flowData = await prepareConfig() + if (!flowData) { + setLoading(false) + return + } + const saveObj = { + name: agentName, + flowData: JSON.stringify(flowData), + type: 'AGENT' + } + try { + let saveResp + if (isNewAgent || !chatflowId) { + // Create new chatflow (new agent or legacy assistant without linked chatflow) + saveResp = await chatflowsApi.createNewChatflow(saveObj) + } else { + saveResp = await chatflowsApi.updateChatflow(chatflowId, saveObj) + } + + if (saveResp.data) { + const newChatflowId = saveResp.data.id + setChatflowId(newChatflowId) + dispatch({ type: SET_CHATFLOW, chatflow: saveResp.data }) + + setLoading(false) + enqueueSnackbar({ + message: 'Agent saved successfully', + options: { + key: new Date().getTime() + Math.random(), + variant: 'success', + action: (key) => ( + + ) + } + }) + // If new agent, navigate to the saved agent's page + if (isNewAgent && newChatflowId) { + navigate(`/agents/${newChatflowId}`, { replace: true }) + } + } + } catch (error) { + setLoading(false) + enqueueSnackbar({ + message: `Failed to save agent: ${ + typeof error.response?.data === 'object' ? error.response.data.message : error.response?.data + }`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + action: (key) => ( + + ) + } + }) + } + } + } + + // ==============================|| Settings & Actions ||============================== // + const onSettingsItemClick = (setting) => { setSettingsOpen(false) - if (setting === 'deleteAssistant') { + if (setting === 'deleteAgent') { handleDeleteFlow() } else if (setting === 'viewMessages') { setViewMessagesDialogProps({ @@ -523,34 +648,139 @@ const CustomAssistantConfigurePreview = () => { chatflow: canvas.chatflow }) setViewLeadsDialogOpen(true) - } else if (setting === 'chatflowConfiguration') { + } else if (setting === 'agentConfiguration') { setChatflowConfigurationDialogProps({ - title: `Assistant Configuration`, + title: `Agent Configuration`, chatflow: canvas.chatflow }) setChatflowConfigurationDialogOpen(true) + } else if (setting === 'saveAsTemplate') { + if (isNewAgent) { + enqueueSnackbar({ + message: 'Please save the agent before exporting as template', + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + return + } + setExportAsTemplateDialogProps({ + title: 'Export As Template', + chatflow: canvas.chatflow + }) + setExportAsTemplateDialogOpen(true) + } else if (setting === 'duplicateAgent') { + handleDuplicateAgent() + } else if (setting === 'exportAgent') { + handleExportAgent() + } + } + + const onUploadFile = (file) => { + setSettingsOpen(false) + handleLoadAgent(file) + } + + const handleDuplicateAgent = async () => { + try { + // Build flowData from current UI state and create a new chatflow directly + const flowData = await prepareConfig() + if (!flowData) return + const saveObj = { + name: `${agentName || 'Agent'} (Copy)`, + flowData: JSON.stringify(flowData), + type: 'AGENT' + } + const createResp = await chatflowsApi.createNewChatflow(saveObj) + if (createResp.data) { + window.open(`${uiBaseURL}/agents/${createResp.data.id}`, '_blank') + } + } catch (e) { + console.error(e) + enqueueSnackbar({ + message: `Failed to duplicate agent: ${e.message || 'Unknown error'}`, + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) + } + } + + const handleExportAgent = () => { + try { + if (!canvas.chatflow?.flowData) return + const flowData = JSON.parse(canvas.chatflow.flowData) + const dataStr = JSON.stringify(generateExportFlowData(flowData, 'AGENT'), null, 2) + const blob = new Blob([dataStr], { type: 'application/json' }) + const dataUri = URL.createObjectURL(blob) + const exportFileDefaultName = `${agentName || 'Agent'} Agent.json` + const linkElement = document.createElement('a') + linkElement.setAttribute('href', dataUri) + linkElement.setAttribute('download', exportFileDefaultName) + linkElement.click() + } catch (e) { + console.error(e) + } + } + + const handleLoadAgent = (file) => { + try { + const flowData = JSON.parse(file) + if (flowData.type && flowData.type !== 'AGENT') { + enqueueSnackbar({ + message: `Invalid file: expected AGENT type but got ${flowData.type}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + return + } + delete flowData.type + // Render the loaded agent in the UI without saving to DB — user must click Save + loadAgentFromFlowData(JSON.stringify(flowData)) + enqueueSnackbar({ + message: 'Agent loaded. Click Save to persist.', + options: { key: new Date().getTime() + Math.random(), variant: 'success' } + }) + } catch (e) { + console.error(e) + enqueueSnackbar({ + message: `Failed to load agent: ${e.message || 'Invalid file'}`, + options: { key: new Date().getTime() + Math.random(), variant: 'error' } + }) } } const handleDeleteFlow = async () => { const confirmPayload = { title: `Delete`, - description: `Delete ${selectedCustomAssistant.name}?`, + description: `Delete ${agentName}?`, confirmButtonName: 'Delete', cancelButtonName: 'Cancel' } const isConfirmed = await confirm(confirmPayload) - if (isConfirmed && customAssistantId) { + if (isConfirmed && !isNewAgent) { try { - const resp = await assistantsApi.deleteAssistant(customAssistantId) - if (resp.data && customAssistantFlowId) { - await chatflowsApi.deleteChatflow(customAssistantFlowId) + if (chatflowId) { + await chatflowsApi.deleteChatflow(chatflowId) } - navigate(-1) + navigate('/agents') } catch (error) { enqueueSnackbar({ - message: typeof error.response.data === 'object' ? error.response.data.message : error.response.data, + message: typeof error.response?.data === 'object' ? error.response.data.message : error.response?.data, options: { key: new Date().getTime() + Math.random(), variant: 'error', @@ -566,17 +796,21 @@ const CustomAssistantConfigurePreview = () => { } } - const onExpandDialogClicked = (value) => { + const [expandDialogTarget, setExpandDialogTarget] = useState(null) + + const onExpandDialogClicked = (value, target = 'instruction', options = {}) => { const dialogProps = { value, inputParam: { - label: 'Instructions', - name: 'instructions', - type: 'string' + label: options.label || 'Instructions', + name: options.name || 'instructions', + type: options.type || 'string' }, + languageType: options.languageType, confirmButtonName: 'Save', cancelButtonName: 'Cancel' } + setExpandDialogTarget(target) setExpandDialogProps(dialogProps) setShowExpandDialog(true) } @@ -599,13 +833,9 @@ const CustomAssistantConfigurePreview = () => { if (resp.data) { setLoading(false) const content = resp.data?.content || resp.data.kwargs?.content - // replace the description of the selected document store const newSelectedDocumentStores = selectedDocumentStores.map((ds) => { if (ds.id === storeId) { - return { - ...ds, - description: content - } + return { ...ds, description: content } } return ds }) @@ -660,9 +890,10 @@ const CustomAssistantConfigurePreview = () => { const onAPIDialogClick = () => { setAPIDialogProps({ title: 'Embed in website or use as API', - chatflowid: customAssistantFlowId, + chatflowid: chatflowId, chatflowApiKeyId: canvas.chatflow.apikeyid, - isSessionMemory: true + isSessionMemory: true, + isAgentCanvas: true }) setAPIDialogOpen(true) } @@ -691,17 +922,115 @@ const CustomAssistantConfigurePreview = () => { setSelectedDocumentStores(newSelectedDocumentStores) } + // ==============================|| Built-in Tools Handlers ||============================== // + + const getBuiltInToolsForParam = (paramName) => { + return builtInTools[paramName] || [] + } + + const handleBuiltInToolToggle = (paramName, toolName) => { + setBuiltInTools((prev) => { + const currentTools = prev[paramName] || [] + const updated = currentTools.includes(toolName) ? currentTools.filter((t) => t !== toolName) : [...currentTools, toolName] + return { ...prev, [paramName]: updated } + }) + } + + // ==============================|| Structured Output Handlers ||============================== // + + const handleStructuredOutputChange = (index, field, value) => { + const updated = [...structuredOutput] + updated[index] = { ...updated[index], [field]: value } + setStructuredOutput(updated) + } + + const addStructuredOutputField = () => { + setStructuredOutput([...structuredOutput, { key: '', type: 'string', description: '' }]) + } + + const removeStructuredOutputField = (index) => { + setStructuredOutput(structuredOutput.filter((_, i) => i !== index)) + } + + // ==============================|| Effects ||============================== // + useEffect(() => { getChatModelsApi.request() getDocStoresApi.request() getToolsApi.request() + // Fetch org defaultConfig for new agents + if (isNewAgent && currentUser?.activeOrganizationId) { + getOrganizationApi.request(currentUser.activeOrganizationId) + } + + // Fetch agentflow node definitions dynamically from server + const fetchNodeDefs = async () => { + try { + const [startResp, agentResp] = await Promise.all([ + nodesApi.getSpecificNode('startAgentflow'), + nodesApi.getSpecificNode('smartAgentAgentflow') + ]) + if (startResp.data) { + const startData = initNode(startResp.data, 'startAgentflow_0') + setStartNodeDef(startData) + } + if (agentResp.data) { + const agentData = initNode(agentResp.data, 'smartAgentAgentflow_0') + setAgentNodeDef(agentData) + } + } catch (err) { + console.error('Error fetching node definitions', err) + } + } + fetchNodeDefs() + + // Fetch vector store and embedding model options + full component definitions + const fetchVSEmbeddingOptions = async () => { + try { + const [vsResp, embResp, vsComponentsResp, embComponentsResp] = await Promise.all([ + nodesApi.executeNodeLoadMethod('smartAgentAgentflow', { loadMethod: 'listVectorStores' }), + nodesApi.executeNodeLoadMethod('smartAgentAgentflow', { loadMethod: 'listEmbeddings' }), + nodesApi.getNodesByCategory('Vector Stores'), + nodesApi.getNodesByCategory('Embeddings') + ]) + if (vsResp.data) { + setVectorStoreOptions( + vsResp.data.map((vs) => ({ + label: vs.label, + name: vs.name, + description: vs.description || '', + imageSrc: `${baseURL}/api/v1/node-icon/${vs.name}` + })) + ) + } + if (embResp.data) { + setEmbeddingModelOptions( + embResp.data.map((em) => ({ + label: em.label, + name: em.name, + description: em.description || '', + imageSrc: `${baseURL}/api/v1/node-icon/${em.name}` + })) + ) + } + if (vsComponentsResp.data) { + setVectorStoreComponents(vsComponentsResp.data.filter((c) => !c.tags?.includes('LlamaIndex'))) + } + if (embComponentsResp.data) { + setEmbeddingModelComponents(embComponentsResp.data.filter((c) => !c.tags?.includes('LlamaIndex'))) + } + } catch (err) { + console.error('Error fetching vector store / embedding options', err) + } + } + fetchVSEmbeddingOptions() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { if (getDocStoresApi.data) { - // Set options const options = getDocStoresApi.data.map((ds) => ({ label: ds.label, name: ds.name, @@ -717,11 +1046,11 @@ const CustomAssistantConfigurePreview = () => { if (getToolsApi.data) { setToolComponents(getToolsApi.data) - // Set options const options = getToolsApi.data.map((ds) => ({ label: ds.label, name: ds.name, - description: ds.description + description: ds.description, + imageSrc: `${baseURL}/api/v1/node-icon/${ds.name}` })) setToolOptions(options) } @@ -729,81 +1058,462 @@ const CustomAssistantConfigurePreview = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [getToolsApi.data]) + // Once toolComponents are loaded, initialize any selectedTools that are bare objects (missing inputParams). + // This handles both orderings: toolComponents loaded before or after selectedTools are set. + const initializeTools = (tools, components) => { + if (components.length === 0 || tools.length === 0) return null + const hasUninit = tools.some((t) => t.name && !t.inputParams) + if (!hasUninit) return null + + return tools.map((tool, index) => { + if (!tool.name || tool.inputParams) return tool + const foundComponent = components.find((c) => c.name === tool.name) + if (!foundComponent) return tool + const toolId = `${foundComponent.name}_${index}` + const clonedComponent = cloneDeep(foundComponent) + const freshTool = initNode(clonedComponent, toolId) + // Restore saved inputs + Object.keys(tool).forEach((key) => { + if (key === 'name' || key === 'toolId') return + if (key === 'credential') { + freshTool.credential = tool[key] + if (freshTool.inputs) { + freshTool.inputs.credential = tool[key] + freshTool.inputs.FLOWISE_CREDENTIAL_ID = tool[key] + } + } else if (freshTool.inputs && key in freshTool.inputs) { + freshTool.inputs[key] = tool[key] + } + }) + return freshTool + }) + } + useEffect(() => { - if (getChatModelsApi.data) { - setChatModelsComponents(getChatModelsApi.data) + const result = initializeTools(selectedTools, toolComponents) + if (result) setSelectedTools(result) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toolComponents]) - // Set options - const options = getChatModelsApi.data.map((chatModel) => ({ - label: chatModel.label, - name: chatModel.name, - imageSrc: `${baseURL}/api/v1/node-icon/${chatModel.name}` - })) - setChatModelsOptions(options) + // Also run when selectedTools changes, but only if toolComponents is ready and tools need init + const prevToolsLenRef = useRef(0) + useEffect(() => { + // Only trigger when selectedTools length changes (new tools loaded from flowData) + if (selectedTools.length !== prevToolsLenRef.current) { + prevToolsLenRef.current = selectedTools.length + const result = initializeTools(selectedTools, toolComponents) + if (result) setSelectedTools(result) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTools]) - if (customAssistantId) { - setLoadingAssistant(true) - getSpecificAssistantApi.request(customAssistantId) + // Initialize VS/embedding nodes when components are loaded and items need initialization + useEffect(() => { + if (vectorStoreComponents.length === 0 && embeddingModelComponents.length === 0) return + if (knowledgeVSEmbeddings.length === 0) return + const hasUninit = knowledgeVSEmbeddings.some( + (item) => (item.vectorStore && !item.vectorStoreNode) || (item.embeddingModel && !item.embeddingNode) + ) + if (!hasUninit) return + + const updated = knowledgeVSEmbeddings.map((item, index) => { + const newItem = { ...item } + if (item.vectorStore && !item.vectorStoreNode) { + const vsNode = initVectorStoreNode(item.vectorStore, index) + if (vsNode && item.vectorStoreConfig) { + // Restore saved inputs + Object.keys(item.vectorStoreConfig).forEach((key) => { + if (key === 'credential') { + vsNode.credential = item.vectorStoreConfig[key] + if (vsNode.inputs) { + vsNode.inputs.credential = item.vectorStoreConfig[key] + vsNode.inputs.FLOWISE_CREDENTIAL_ID = item.vectorStoreConfig[key] + } + } else if (key === 'agentSelectedTool') { + // skip + } else if (vsNode.inputs && key in vsNode.inputs) { + vsNode.inputs[key] = item.vectorStoreConfig[key] + } + }) + } + newItem.vectorStoreNode = vsNode + } + if (item.embeddingModel && !item.embeddingNode) { + const embNode = initEmbeddingNode(item.embeddingModel, index) + if (embNode && item.embeddingModelConfig) { + Object.keys(item.embeddingModelConfig).forEach((key) => { + if (key === 'credential') { + embNode.credential = item.embeddingModelConfig[key] + if (embNode.inputs) { + embNode.inputs.credential = item.embeddingModelConfig[key] + embNode.inputs.FLOWISE_CREDENTIAL_ID = item.embeddingModelConfig[key] + } + } else if (key === 'agentSelectedTool') { + // skip + } else if (embNode.inputs && key in embNode.inputs) { + embNode.inputs[key] = item.embeddingModelConfig[key] + } + }) + } + newItem.embeddingNode = embNode } + return newItem + }) + setKnowledgeVSEmbeddings(updated) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vectorStoreComponents, embeddingModelComponents, knowledgeVSEmbeddings]) + + // Helper to restore a chat model from saved data + const restoreChatModel = (savedModel) => { + if (!savedModel) return + const latestComponent = chatModelsComponents.find((c) => c.name === savedModel.name) + if (latestComponent) { + const chatModelId = `${latestComponent.name}_0` + const clonedComponent = cloneDeep(latestComponent) + const freshModel = initNode(clonedComponent, chatModelId) + freshModel.credential = savedModel.credential || '' + if (savedModel.inputs) { + Object.keys(savedModel.inputs).forEach((key) => { + if (freshModel.inputs && key in freshModel.inputs) { + freshModel.inputs[key] = savedModel.inputs[key] + } + }) + } + // Ensure credential is set in inputs for DocStoreInputHandler/CredentialInputHandler to read + if (freshModel.credential && freshModel.inputs) { + freshModel.inputs.credential = freshModel.credential + freshModel.inputs.FLOWISE_CREDENTIAL_ID = freshModel.credential + } + setSelectedChatModel(freshModel) + } else { + setSelectedChatModel(savedModel) } + } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getChatModelsApi.data]) + // Helper to extract agent config from flowData + const loadAgentFromFlowData = (flowDataStr) => { + try { + const flowData = JSON.parse(flowDataStr) + + // ---- New agentflow format (smartAgentAgentflow node) ---- + const agentNode = flowData.nodes?.find((n) => n.data?.name === 'smartAgentAgentflow') + if (agentNode) { + const inputs = agentNode.data?.inputs || {} + const modelConfig = inputs.agentModelConfig || {} + + // Restore chat model + if (inputs.agentModel && modelConfig) { + const savedModel = { + name: inputs.agentModel, + credential: modelConfig.credential || '', + inputs: { ...modelConfig } + } + delete savedModel.inputs.credential + delete savedModel.inputs.agentModel + restoreChatModel(savedModel) + } - useEffect(() => { - if (getSpecificAssistantApi.data) { - setLoadingAssistant(false) - try { - const assistantDetails = JSON.parse(getSpecificAssistantApi.data.details) - setSelectedCustomAssistant(assistantDetails) + // Instruction from messages + if (inputs.agentMessages?.length > 0) { + const systemMsg = inputs.agentMessages.find((m) => m.role === 'system') + if (systemMsg?.content) setCustomAssistantInstruction(systemMsg.content) + } + + // Built-in tools — handle both stringified and raw array formats + const parseBuiltInTools = (val) => { + if (!val) return [] + if (typeof val === 'string') { + try { + return JSON.parse(val) + } catch { + return [] + } + } + return Array.isArray(val) ? val : [] + } + // Dynamically load all built-in tool params + const loadedBuiltInTools = {} + for (const key of Object.keys(inputs)) { + if (key.startsWith('agentToolsBuiltIn') && inputs[key]) { + loadedBuiltInTools[key] = parseBuiltInTools(inputs[key]) + } + } + if (Object.keys(loadedBuiltInTools).length > 0) setBuiltInTools(loadedBuiltInTools) + + // Tools + if (inputs.agentTools?.length > 0) { + const tools = inputs.agentTools.map((t) => ({ + name: t.agentSelectedTool, + toolId: t.agentSelectedTool, + requireHumanInput: t.agentSelectedToolRequiresHumanInput ?? false, + ...(t.agentSelectedToolConfig || {}) + })) + setSelectedTools(tools) + } - if (assistantDetails.chatModel) { - setSelectedChatModel(assistantDetails.chatModel) + // Knowledge - Document Stores + if (inputs.agentKnowledgeDocumentStores?.length > 0) { + const docStores = inputs.agentKnowledgeDocumentStores.map((ds) => { + // documentStore format is "storeId:storeName" + // Extract storeId (UUID) for dropdown matching and storeName for display + const compositeId = ds.documentStore || '' + const colonIdx = compositeId.indexOf(':') + const storeId = colonIdx > -1 ? compositeId.substring(0, colonIdx) : compositeId + const displayName = colonIdx > -1 ? compositeId.substring(colonIdx + 1) : compositeId + return { + id: storeId, + name: displayName, + description: ds.docStoreDescription || '', + returnSourceDocuments: ds.returnSourceDocuments || false + } + }) + setSelectedDocumentStores(docStores) } - if (assistantDetails.instruction) { - setCustomAssistantInstruction(assistantDetails.instruction) + // Knowledge - Vector Embeddings + if (inputs.agentKnowledgeVSEmbeddings?.length > 0) { + setKnowledgeVSEmbeddings(inputs.agentKnowledgeVSEmbeddings) } - if (assistantDetails.flowId) { - setCustomAssistantFlowId(assistantDetails.flowId) - getSpecificChatflowApi.request(assistantDetails.flowId) + // Memory + if (inputs.agentEnableMemory !== undefined) setEnableMemory(inputs.agentEnableMemory) + if (inputs.agentMemoryType) setMemoryType(inputs.agentMemoryType) + if (inputs.agentMemoryWindowSize !== undefined) setMemoryWindowSize(Number(inputs.agentMemoryWindowSize)) + if (inputs.agentMemoryMaxTokenLimit !== undefined) setMemoryMaxTokenLimit(Number(inputs.agentMemoryMaxTokenLimit)) + + // Structured Output + if (inputs.agentStructuredOutput?.length > 0) setStructuredOutput(inputs.agentStructuredOutput) + + return true + } + + // ---- Old format (toolAgent node) — backward compatibility ---- + const toolAgentNode = flowData.nodes?.find((n) => n.data?.name === 'toolAgent') + if (toolAgentNode) { + const toolAgentInputs = toolAgentNode.data?.inputs || {} + + // Instruction from systemMessage + if (toolAgentInputs.systemMessage) { + setCustomAssistantInstruction(toolAgentInputs.systemMessage) } - if (assistantDetails.documentStores) { - setSelectedDocumentStores(assistantDetails.documentStores) + // Chat model — find the node connected to toolAgent's model input + const chatModelNode = flowData.nodes?.find((n) => n.data?.category === 'Chat Models') + if (chatModelNode) { + const credentialId = + chatModelNode.data.credential || + chatModelNode.data.inputs?.credential || + chatModelNode.data.inputs?.FLOWISE_CREDENTIAL_ID || + '' + const savedModel = { + name: chatModelNode.data.name, + credential: credentialId, + inputs: { ...(chatModelNode.data.inputs || {}) } + } + // Keep credential in inputs so it gets copied to freshModel.inputs + // but remove FLOWISE_CREDENTIAL_ID as it's redundant + if (credentialId) savedModel.inputs.credential = credentialId + delete savedModel.inputs.FLOWISE_CREDENTIAL_ID + restoreChatModel(savedModel) } - if (assistantDetails.tools) { - setSelectedTools(assistantDetails.tools) + // Memory — if a memory node exists, enable memory + const memoryNode = flowData.nodes?.find((n) => n.data?.category === 'Memory') + if (memoryNode) { + setEnableMemory(true) + // Map old memory node types to new memory types + const memoryName = memoryNode.data?.name || '' + if (memoryName.includes('windowMemory') || memoryName.includes('BufferWindowMemory')) { + setMemoryType('windowSize') + const windowSize = memoryNode.data?.inputs?.k || memoryNode.data?.inputs?.size + if (windowSize) setMemoryWindowSize(Number(windowSize)) + } else if (memoryName.includes('conversationSummaryMemory')) { + setMemoryType('conversationSummary') + } else { + setMemoryType('allMessages') + } } - } catch (error) { - console.error('Error parsing assistant details', error) + + // Tools — parse from edges and nodes + // Old format: toolAgent has inputs.tools = ["{{retrieverTool_0}}", "{{calculator_0}}"] + // We need to find tool nodes that connect to toolAgent + const toolNodeIds = new Set() + const retrieverToolNodes = [] + const directToolNodes = [] + + // Find all nodes connected to toolAgent's tools input via edges + if (flowData.edges) { + for (const edge of flowData.edges) { + if (edge.target === toolAgentNode.data.id && edge.targetHandle?.includes('tools')) { + const sourceNode = flowData.nodes?.find((n) => n.data?.id === edge.source || n.id === edge.source) + if (sourceNode) { + toolNodeIds.add(sourceNode.data?.id || sourceNode.id) + if (sourceNode.data?.name === 'retrieverTool') { + retrieverToolNodes.push(sourceNode) + } else if (sourceNode.data?.category === 'Tools' || sourceNode.data?.baseClasses?.includes('Tool')) { + directToolNodes.push(sourceNode) + } + } + } + } + } + + // Map direct tool nodes (calculator, etc.) to selectedTools format + // Include credential and saved inputs so they can be restored by initializeTools + const parsedTools = directToolNodes.map((toolNode) => { + const toolData = toolNode.data || {} + const result = { + name: toolData.name, + toolId: toolData.name + } + // Carry over credential + const credentialId = toolData.credential || toolData.inputs?.credential || toolData.inputs?.FLOWISE_CREDENTIAL_ID || '' + if (credentialId) result.credential = credentialId + // Carry over saved inputs + if (toolData.inputs) { + Object.keys(toolData.inputs).forEach((key) => { + if (key === 'credential' || key === 'FLOWISE_CREDENTIAL_ID') return + result[key] = toolData.inputs[key] + }) + } + return result + }) + if (parsedTools.length > 0) { + setSelectedTools(parsedTools) + } + + // Map retrieverTool nodes to document stores + // retrieverTool connects to a vectorStore (documentStoreVS) which has inputs.selectedStore + const parsedDocStores = [] + for (const rt of retrieverToolNodes) { + const rtInputs = rt.data?.inputs || {} + // Find the document store node connected to this retriever tool + const dsEdge = flowData.edges?.find((e) => e.target === (rt.data?.id || rt.id) && e.targetHandle?.includes('retriever')) + if (dsEdge) { + const dsNode = flowData.nodes?.find((n) => (n.data?.id || n.id) === dsEdge.source) + if (dsNode?.data?.inputs?.selectedStore) { + const storeId = dsNode.data.inputs.selectedStore + const storeName = rtInputs.name || storeId + parsedDocStores.push({ + id: storeId, + name: storeName, + description: rtInputs.description || '', + returnSourceDocuments: rtInputs.returnSourceDocuments || false + }) + } + } + } + if (parsedDocStores.length > 0) { + setSelectedDocumentStores(parsedDocStores) + } + + return true + } + + return false + } catch (e) { + console.error('Error loading agent from flowData', e) + return false + } + } + + useEffect(() => { + if (getChatModelsApi.data) { + setChatModelsComponents(getChatModelsApi.data) + + const options = getChatModelsApi.data.map((chatModel) => ({ + label: chatModel.label, + name: chatModel.name, + imageSrc: `${baseURL}/api/v1/node-icon/${chatModel.name}` + })) + setChatModelsOptions(options) + + if (!isNewAgent && !isTemplatePreview) { + setLoadingAssistant(true) + getSpecificChatflowApi.request(chatflowId) + } else { + setLoadingAssistant(false) } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getSpecificAssistantApi.data]) + }, [getChatModelsApi.data]) + // TODO: Replace mocked defaultConfig with actual governance settings from database useEffect(() => { - if (getSpecificChatflowApi.data) { - const chatflow = getSpecificChatflowApi.data - dispatch({ type: SET_CHATFLOW, chatflow }) - } else if (getSpecificChatflowApi.error) { - setError(`Failed to retrieve: ${getSpecificChatflowApi.error.response.data.message}`) + if (defaultCheckComplete) return + // Not a new agent or no org to check — nothing to wait for + if (!isNewAgent || !currentUser?.activeOrganizationId) { + setDefaultCheckComplete(true) + return } + // Need both chat models and org response before we can decide + if (!getChatModelsApi.data || getOrganizationApi.data === null) return + try { + if (getOrganizationApi.data?.defaultConfig) { + const config = JSON.parse(getOrganizationApi.data.defaultConfig) + if (config.chatModel) { + const saved = config.chatModel + const foundComponent = getChatModelsApi.data.find((c) => c.name === saved.name) + if (foundComponent) { + const chatModelId = `${foundComponent.name}_0` + const clonedComponent = cloneDeep(foundComponent) + const restored = initNode(clonedComponent, chatModelId) + if (saved.inputs) { + restored.inputs = { ...restored.inputs, ...saved.inputs } + } + // Restore credential reference + if (saved.credentialId || saved.credential) { + restored.credential = saved.credentialId || saved.credential + restored.inputs.FLOWISE_CREDENTIAL_ID = restored.credential + } + restored.inputParams = showHideInputParams(restored) + setSelectedChatModel(restored) + setModelConfirmed(true) + } + } + } + } catch (e) { + console.error('Error parsing defaultConfig', e) + } finally { + setDefaultCheckComplete(true) + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getSpecificChatflowApi.data, getSpecificChatflowApi.error]) + }, [getChatModelsApi.data, getOrganizationApi.data, isNewAgent, currentUser?.activeOrganizationId]) useEffect(() => { - if (getSpecificAssistantApi.error) { + if (getSpecificChatflowApi.data) { + const chatflow = getSpecificChatflowApi.data + dispatch({ type: SET_CHATFLOW, chatflow }) + + // Set agent name from chatflow + let name = chatflow.name + if (!name || name === 'Untitled') { + try { + const fd = chatflow.flowData ? JSON.parse(chatflow.flowData) : null + const agentNode = fd?.nodes?.find((n) => n.data?.name === 'smartAgentAgentflow') + if (agentNode?.data?.label && agentNode.data.label !== 'Agent 0') { + name = agentNode.data.label + } + } catch { + // ignore + } + } + setAgentName(name || 'Untitled Agent') + setLoadingAssistant(false) + + // Load agent config from flowData (handles both old toolAgent and new smartAgentAgentflow formats) + if (chatflow.flowData) { + loadAgentFromFlowData(chatflow.flowData) + } + } else if (getSpecificChatflowApi.error) { setLoadingAssistant(false) - setError(getSpecificAssistantApi.error) + setError(`Failed to retrieve: ${getSpecificChatflowApi.error?.response?.data?.message || 'Unknown error'}`) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getSpecificAssistantApi.error]) + }, [getSpecificChatflowApi.data, getSpecificChatflowApi.error]) useEffect(() => { if (getChatModelsApi.error) { @@ -821,8 +1531,23 @@ const CustomAssistantConfigurePreview = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [getDocStoresApi.error]) + // Load agent from marketplace template data passed via navigation state + useEffect(() => { + if (isNewAgent && location.state?.templateFlowData && getChatModelsApi.data) { + loadAgentFromFlowData(location.state.templateFlowData) + } + // Template preview mode — load from templateData + if (isTemplatePreview && templateData?.flowData && getChatModelsApi.data) { + loadAgentFromFlowData(templateData.flowData) + setAgentName(templateData.templateName || templateData.name || 'Template Agent') + setLoadingAssistant(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.state, getChatModelsApi.data]) + const defaultWidth = () => { - if (customAssistantFlowId && !loadingAssistant) { + if (isTemplatePreview) return 12 + if (!isNewAgent && !loadingAssistant) { return 6 } return 12 @@ -832,6 +1557,199 @@ const CustomAssistantConfigurePreview = () => { return window.innerHeight - 130 } + // ==============================|| Render Helpers ||============================== // + + const renderBuiltInToolsSection = () => { + const modelName = selectedChatModel?.name + const builtInToolsMap = getBuiltInToolsMap(agentNodeDef) + const builtInConfig = builtInToolsMap[modelName] + if (!builtInConfig) return null + + const currentTools = getBuiltInToolsForParam(builtInConfig.paramName) + + return ( + + + Built-in Tools + + + + {builtInConfig.options.map((tool) => ( + handleBuiltInToolToggle(builtInConfig.paramName, tool.name)} + /> + } + label={ + + {tool.label} + + - {tool.description} + + + } + /> + ))} + + + ) + } + + const renderStructuredOutputSection = () => { + return ( + + + JSON Structured Output + + + {structuredOutput.map((item, index) => ( + + + + removeStructuredOutputField(index)}> + + + + +
+ + Key * + + handleStructuredOutputChange(index, 'key', e.target.value)} + fullWidth + /> +
+
+ + Type * + + handleStructuredOutputChange(index, 'type', newValue || 'string')} + value={item.type || 'string'} + /> +
+ {item.type === 'enum' && ( +
+ + Enum Values + + handleStructuredOutputChange(index, 'enumValues', e.target.value)} + fullWidth + /> +
+ )} + {item.type === 'jsonArray' && ( +
+ + + JSON Schema + + + + onExpandDialogClicked(item.jsonSchema || '', `jsonSchema_${index}`, { + label: 'JSON Schema', + name: 'jsonSchema', + type: 'code', + languageType: 'json' + }) + } + > + + + +
+ handleStructuredOutputChange(index, 'jsonSchema', code)} + basicSetup={{ highlightActiveLine: false, highlightActiveLineGutter: false }} + /> +
+
+ )} +
+ + Description * + + handleStructuredOutputChange(index, 'description', e.target.value)} + fullWidth + /> +
+
+
+ ))} + +
+ ) + } + + // ==============================|| Render ||============================== // + return ( <> @@ -869,56 +1787,204 @@ const CustomAssistantConfigurePreview = () => { > - - {selectedCustomAssistant?.name ?? ''} - + {isEditingName ? ( + + setEditingNameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + saveAgentName(editingNameValue) + } else if (e.key === 'Escape') { + setIsEditingName(false) + } + }} + placeholder='Agent Name' + /> + saveAgentName(editingNameValue)} + > + + + setIsEditingName(false)} + > + + + + ) : ( + + {agentName || 'Untitled'} + {!isTemplatePreview && ( + { + setEditingNameValue(agentName) + setIsEditingName(true) + }} + > + + + )} + + )}
- {customAssistantFlowId && !loadingAssistant && ( - - - - - - )} - - - + { + if (!newValue) { + setSelectedChatModel({}) + } else if (newValue !== selectedChatModel?.name) { + const found = chatModelsComponents.find( + (c) => c.name === newValue + ) + if (found) { + previousChatModelRef.current = selectedChatModel + const id = `${found.name}_0` + const cloned = cloneDeep(found) + setSelectedChatModel(initNode(cloned, id)) + setModelConfigDialogOpen(true) + } + } + }} + value={selectedChatModel?.name || 'choose an option'} + disableClearable + /> + + setModelConfigDialogOpen(true)} + sx={{ + color: customization.isDarkMode + ? theme.palette.common.white + : theme.palette.text.primary + }} + > + + + + )} + {isTemplatePreview ? ( + + + navigate('/agents/new', { + state: { templateFlowData: templateData.flowData } + }) + } + startIcon={} > - - - - - {customAssistantFlowId && !loadingAssistant && ( + Use Template + + + ) : ( + <> + {!isNewAgent && !loadingAssistant && ( + + + + + + )} + {isNewAgent && creationMode !== 'describe' && ( + + + loadAgentInputRef.current?.click()} + > + + + + + )} + {!(isNewAgent && creationMode === 'describe') && ( + + + + + + + + )} + + )} + {!isNewAgent && !loadingAssistant && !isTemplatePreview && ( { )} - {!customAssistantFlowId && !loadingAssistant && ( - - - - - - - - )} - -
- - Select Model * - -
- { - 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) + + {/* Mode toggle for new agents */} + {isNewAgent && !isTemplatePreview && ( + + { + if (newMode !== null) setCreationMode(newMode) + }} + sx={{ + borderRadius: '24px', + backgroundColor: theme.palette.grey[100], + ...(customization.isDarkMode && { + backgroundColor: theme.palette.grey[800] + }), + '& .MuiToggleButtonGroup-grouped': { + border: 'none', + borderRadius: '24px !important', + px: 3, + py: 0.75, + textTransform: 'none', + fontWeight: 600, + fontSize: '0.875rem', + color: theme.palette.text.secondary, + '&.Mui-selected': { + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + boxShadow: '0 1px 3px rgba(0,0,0,0.12)', + '&:hover': { + backgroundColor: theme.palette.background.paper + } + }, + '&:hover': { + backgroundColor: 'transparent' + } } - } - }} - value={selectedChatModel ? selectedChatModel?.name : 'choose an option'} + }} + > + Describe + Manual + + + )} + + {/* Describe mode */} + {isNewAgent && !isTemplatePreview && creationMode === 'describe' && ( + -
+ )} + {/* Form content — disabled in template preview mode, hidden in describe mode */} - - - Instructions * - -
- + + + Select Model * + + + { + 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) + } + } }} - title='Expand' - color='secondary' - onClick={() => onExpandDialogClicked(customAssistantInstruction)} - > - - - {selectedChatModel?.name && ( - + }> + + + + {selectedChatModel?.label || selectedChatModel?.name} Parameters + + + + + {showHideInputParams(selectedChatModel) + .filter((inputParam) => !inputParam.hidden && inputParam.display !== false) + .map((inputParam, index) => ( + + ))} + + )} -
- setCustomAssistantInstruction(event.target.value)} - /> -
- - - Knowledge (Document Stores) - - - { - if (!newValue) { - setSelectedDocumentStores([]) - } else { - onDocStoreItemSelected(newValue) - } + + + {/* Instructions */} + ds.id) ?? 'choose an option'} - /> - {selectedDocumentStores.length > 0 && ( - + > + - Describe Knowledge * + Instructions * - +
+ onExpandDialogClicked(customAssistantInstruction)} + > + + + {selectedChatModel?.name && ( + + )}
- )} - {selectedDocumentStores.map((ds, index) => { - return ( - - -
- {ds.name} - onDocStoreItemDelete(ds.id)} - > - - -
-
- {selectedChatModel?.name && ( - - )} -
- { - const newSelectedDocumentStores = [...selectedDocumentStores] - newSelectedDocumentStores[index].description = event.target.value - setSelectedDocumentStores(newSelectedDocumentStores) - }} - /> - - Return Source Documents - - - { - const newSelectedDocumentStores = [...selectedDocumentStores] - newSelectedDocumentStores[index].returnSourceDocuments = newValue - setSelectedDocumentStores(newSelectedDocumentStores) + setCustomAssistantInstruction(event.target.value)} + /> +
+ + {/* Built-in Tools (conditional on model) */} + {selectedChatModel?.name && renderBuiltInToolsSection()} + + {/* Tools */} + + + Tools + + + {selectedTools.map((tool, index) => { + return ( + - - ) - })} - - {selectedChatModel && Object.keys(selectedChatModel).length > 0 && ( + key={index} + > + +
+ + Tool * + +
+ { + const newSelectedTools = selectedTools.filter( + (t, i) => i !== index + ) + setSelectedTools(newSelectedTools) + }} + > + + +
+ { + if (!newValue) { + const newSelectedTools = [...selectedTools] + newSelectedTools[index] = {} + setSelectedTools(newSelectedTools) + } else { + const foundToolComponent = toolComponents.find( + (tool) => tool.name === newValue + ) + if (foundToolComponent) { + const toolId = `${foundToolComponent.name}_${index}` + const clonedComponent = cloneDeep(foundToolComponent) + const initToolData = initNode(clonedComponent, toolId) + const newSelectedTools = [...selectedTools] + newSelectedTools[index] = initToolData + setSelectedTools(newSelectedTools) + } + } + }} + value={tool?.name || 'choose an option'} + /> +
+ {tool && Object.keys(tool).length === 0 && ( + + )} + {tool && Object.keys(tool).length > 0 && ( + + {showHideInputParams(tool) + .filter( + (inputParam) => + !inputParam.hidden && inputParam.display !== false + ) + .map((inputParam, inputIndex) => ( + + ))} + + Require Human Input + { + const newSelectedTools = [...selectedTools] + newSelectedTools[index] = { + ...newSelectedTools[index], + requireHumanInput: newValue + } + setSelectedTools(newSelectedTools) + }} + /> + + + )} + + ) + })} + +
+ + {/* Knowledge (Document Stores) */} { borderRadius: 2 }} > - {showHideInputParams(selectedChatModel) - .filter((inputParam) => !inputParam.hidden && inputParam.display !== false) - .map((inputParam, index) => ( - + Knowledge (Document Stores) + + + { + if (!newValue) { + setSelectedDocumentStores([]) + } else { + onDocStoreItemSelected(newValue) + } + }} + value={selectedDocumentStores.map((ds) => ds.id) ?? 'choose an option'} + /> + {selectedDocumentStores.map((ds, index) => { + return ( + - ))} + sx={{ + p: 2, + mt: 1, + mb: 1, + border: 1, + borderColor: theme.palette.grey[900] + 25, + borderRadius: 2 + }} + > + + + {ds.name} + + + + onDocStoreItemDelete(ds.id)} + > + + + + + +
+ + + Describe Knowledge * + + {selectedChatModel?.name && ( + + )} + + { + const newSelectedDocumentStores = [...selectedDocumentStores] + newSelectedDocumentStores[index].description = + event.target.value + setSelectedDocumentStores(newSelectedDocumentStores) + }} + /> +
+
+ Return Source Documents + { + const newSelectedDocumentStores = [...selectedDocumentStores] + newSelectedDocumentStores[index].returnSourceDocuments = + newValue + setSelectedDocumentStores(newSelectedDocumentStores) + }} + /> +
+
+
+ ) + })}
- )} - - - Tools - - - {selectedTools.map((tool, index) => { - return ( + + {/* Knowledge (Vector Embeddings) */} + + + Knowledge (Vector Embeddings) + + + {knowledgeVSEmbeddings.map((item, index) => ( - -
- - Tool * + + + { + setKnowledgeVSEmbeddings( + knowledgeVSEmbeddings.filter((_, i) => i !== index) + ) + }} + > + + + + +
+ + Vector Store * -
- { - const newSelectedTools = selectedTools.filter((t, i) => i !== index) - setSelectedTools(newSelectedTools) + { + const updated = [...knowledgeVSEmbeddings] + const vsNode = newValue + ? initVectorStoreNode(newValue, index) + : null + updated[index] = { + ...updated[index], + vectorStore: newValue || '', + vectorStoreNode: vsNode + } + setKnowledgeVSEmbeddings(updated) }} - > - - + value={item.vectorStore || 'choose an option'} + /> + {item.vectorStoreNode && ( + + }> + + + + {item.vectorStoreNode.label || + item.vectorStoreNode.name}{' '} + Parameters + + + + + {showHideInputParams(item.vectorStoreNode) + .filter( + (inputParam) => + !inputParam.hidden && inputParam.display !== false + ) + .map((inputParam, paramIndex) => ( + + ))} + + + )}
- { - if (!newValue) { - const newSelectedTools = [...selectedTools] - newSelectedTools[index] = {} - setSelectedTools(newSelectedTools) - } else { - const foundToolComponent = toolComponents.find( - (tool) => tool.name === newValue - ) - if (foundToolComponent) { - const toolId = `${foundToolComponent.name}_${index}` - const clonedComponent = cloneDeep(foundToolComponent) - const initToolData = initNode(clonedComponent, toolId) - const newSelectedTools = [...selectedTools] - newSelectedTools[index] = initToolData - setSelectedTools(newSelectedTools) +
+ + Embedding Model * + + { + const updated = [...knowledgeVSEmbeddings] + const embNode = newValue ? initEmbeddingNode(newValue, index) : null + updated[index] = { + ...updated[index], + embeddingModel: newValue || '', + embeddingNode: embNode } - } - }} - value={tool?.name || 'choose an option'} - /> - - {tool && Object.keys(tool).length === 0 && ( - - )} - {tool && Object.keys(tool).length > 0 && ( - - {showHideInputParams(tool) - .filter( - (inputParam) => !inputParam.hidden && inputParam.display !== false - ) - .map((inputParam, inputIndex) => ( - - ))} - - )} + setKnowledgeVSEmbeddings(updated) + }} + value={item.embeddingModel || 'choose an option'} + /> + {item.embeddingNode && ( + + }> + + + + {item.embeddingNode.label || item.embeddingNode.name}{' '} + Parameters + + + + + {showHideInputParams(item.embeddingNode) + .filter( + (inputParam) => + !inputParam.hidden && inputParam.display !== false + ) + .map((inputParam, paramIndex) => ( + + ))} + + + )} +
+
+ + Knowledge Name * + + { + const updated = [...knowledgeVSEmbeddings] + updated[index] = { + ...updated[index], + knowledgeName: e.target.value + } + setKnowledgeVSEmbeddings(updated) + }} + /> +
+
+ + Describe Knowledge * + + { + const updated = [...knowledgeVSEmbeddings] + updated[index] = { + ...updated[index], + knowledgeDescription: e.target.value + } + setKnowledgeVSEmbeddings(updated) + }} + /> +
+
+ Return Source Documents + { + const updated = [...knowledgeVSEmbeddings] + updated[index] = { + ...updated[index], + returnSourceDocuments: newValue + } + setKnowledgeVSEmbeddings(updated) + }} + /> +
+
- ) - })} - - - {selectedChatModel && Object.keys(selectedChatModel).length > 0 && ( - + ))} + + {renderStructuredOutputSection()} + + {/* End form content wrapper */} + + {/* Save & Load Buttons — hidden in template preview and describe mode */} + {!isTemplatePreview && !(isNewAgent && creationMode === 'describe') && ( + + + {isNewAgent && ( + <> + { + if (!e.target.files?.[0]) return + const reader = new FileReader() + reader.onload = (evt) => { + if (evt?.target?.result) handleLoadAgent(evt.target.result) + } + reader.readAsText(e.target.files[0]) + e.target.value = null + }} + /> + + + )} + + )}
- {customAssistantFlowId && !loadingAssistant && ( + {!isNewAgent && !loadingAssistant && !isTemplatePreview && ( {customization.isDarkMode && ( { )} {!customization.isDarkMode && ( { anchorEl={settingsRef.current} onClose={() => setSettingsOpen(false)} onSettingsItemClick={onSettingsItemClick} + onUploadFile={onUploadFile} isCustomAssistant={true} /> )} @@ -1406,7 +2828,7 @@ const CustomAssistantConfigurePreview = () => { /> setViewLeadsDialogOpen(false)} /> setChatflowConfigurationDialogOpen(false)} @@ -1425,13 +2847,102 @@ const CustomAssistantConfigurePreview = () => { dialogProps={expandDialogProps} onCancel={() => setShowExpandDialog(false)} onConfirm={(newValue) => { - setCustomAssistantInstruction(newValue) + if (expandDialogTarget === 'instruction') { + setCustomAssistantInstruction(newValue) + } else if (expandDialogTarget?.startsWith('jsonSchema_')) { + const idx = parseInt(expandDialogTarget.split('_')[1]) + handleStructuredOutputChange(idx, 'jsonSchema', newValue) + } setShowExpandDialog(false) }} >
+ setExportAsTemplateDialogOpen(false)} + /> + { + if (previousChatModelRef.current) { + setSelectedChatModel(previousChatModelRef.current) + previousChatModelRef.current = null + } + setModelConfigDialogOpen(false) + }} + fullWidth + maxWidth='sm' + > + + + {selectedChatModel?.name && ( + + )} + {selectedChatModel?.label || selectedChatModel?.name} Configuration + + + + {selectedChatModel && + Object.keys(selectedChatModel).length > 0 && + showHideInputParams(selectedChatModel) + .filter( + (ip) => + !ip.hidden && + ip.display !== false && + ['credential', 'model', 'modelName', 'customModel', 'customModelName'].includes(ip.name) + ) + .map((ip, idx) => ( + + ))} + + + + { + previousChatModelRef.current = null + setModelConfigDialogOpen(false) + }} + > + Confirm + + + ) } +CustomAssistantConfigurePreview.propTypes = { + chatflowType: PropTypes.string +} + export default CustomAssistantConfigurePreview diff --git a/packages/ui/src/views/assistants/custom/CustomAssistantLayout.jsx b/packages/ui/src/views/assistants/custom/CustomAssistantLayout.jsx index 6597bfeb6e7..76fcda80ae9 100644 --- a/packages/ui/src/views/assistants/custom/CustomAssistantLayout.jsx +++ b/packages/ui/src/views/assistants/custom/CustomAssistantLayout.jsx @@ -1,8 +1,26 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' +import moment from 'moment' // material-ui -import { Box, Stack, Skeleton } from '@mui/material' +import { + Box, + Fade, + Paper, + Skeleton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + ToggleButton, + ToggleButtonGroup, + Typography +} from '@mui/material' +import { useTheme, styled } from '@mui/material/styles' +import { tableCellClasses } from '@mui/material/TableCell' // project imports import ViewHeader from '@/layout/MainLayout/ViewHeader' @@ -13,6 +31,7 @@ import AssistantEmptySVG from '@/assets/images/assistant_empty.svg' import AddCustomAssistantDialog from './AddCustomAssistantDialog' import ErrorBoundary from '@/ErrorBoundary' import { StyledPermissionButton } from '@/ui-component/button/RBACButtons' +import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog' // API import assistantsApi from '@/api/assistants' @@ -21,12 +40,30 @@ import assistantsApi from '@/api/assistants' import useApi from '@/hooks/useApi' // icons -import { IconPlus } from '@tabler/icons-react' +import { IconPlus, IconLayoutGrid, IconList } 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)(() => ({ + '&:last-child td, &:last-child th': { + border: 0 + } +})) // ==============================|| CustomAssistantLayout ||============================== // const CustomAssistantLayout = () => { const navigate = useNavigate() + const theme = useTheme() const getAllAssistantsApi = useApi(assistantsApi.getAllAssistants) @@ -34,15 +71,22 @@ const CustomAssistantLayout = () => { const [error, setError] = useState(null) const [showDialog, setShowDialog] = useState(false) const [dialogProps, setDialogProps] = useState({}) + const [view, setView] = useState(localStorage.getItem('agentDisplayStyle') || 'card') const [search, setSearch] = useState('') const onSearchChange = (event) => { setSearch(event.target.value) } + const handleChange = (event, nextView) => { + if (nextView === null) return + localStorage.setItem('agentDisplayStyle', nextView) + setView(nextView) + } + const addNew = () => { const dialogProp = { - title: 'Add New Custom Assistant', + title: 'Add New Agent', type: 'ADD', cancelButtonName: 'Cancel', confirmButtonName: 'Add' @@ -53,7 +97,7 @@ const CustomAssistantLayout = () => { const onConfirm = (assistantId) => { setShowDialog(false) - navigate(`/assistants/custom/${assistantId}`) + navigate(`/agents/${assistantId}`) } function filterAssistants(data) { @@ -73,7 +117,6 @@ const CustomAssistantLayout = () => { useEffect(() => { getAllAssistantsApi.request('CUSTOM') - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -87,67 +130,164 @@ const CustomAssistantLayout = () => { } }, [getAllAssistantsApi.error]) + const totalAgents = getAllAssistantsApi.data?.length || 0 + return ( <> {error ? ( ) : ( - - navigate(-1)} - > - } + + + - Add - - - {isLoading ? ( - - - - - - ) : ( - - {getAllAssistantsApi.data && - getAllAssistantsApi.data?.filter(filterAssistants).map((data, index) => ( - navigate('/assistants/custom/' + data.id)} - /> - ))} - - )} - {!isLoading && (!getAllAssistantsApi.data || getAllAssistantsApi.data.length === 0) && ( - - - AssistantEmptySVG + + + + + + + + + } + > + Add New + + + + {isLoading && ( + + + + -
No Custom Assistants Added Yet
-
- )} -
+ )} + + {!isLoading && totalAgents > 0 && ( + <> + {!view || view === 'card' ? ( + + {getAllAssistantsApi.data?.filter(filterAssistants).map((data, index) => ( + navigate('/agents/' + data.id)} + /> + ))} + + ) : ( + + + + + Name + Model + Last Updated + Actions + + + + {getAllAssistantsApi.data?.filter(filterAssistants).map((data, index) => { + const details = JSON.parse(data.details) + return ( + navigate('/agents/' + data.id)} + > + + + {details.chatModel?.name && ( + + )} + {details.name} + + + + + {details.chatModel?.label || details.chatModel?.name || '-'} + + + + + {data.updatedDate + ? moment(data.updatedDate).format('MMM D, YYYY') + : '-'} + + + + + ) + })} + +
+
+ )} + + )} + + {!isLoading && totalAgents === 0 && ( + + + AssistantEmptySVG + +
No Agents Added Yet
+
+ )} + + )}
{ onCancel={() => setShowDialog(false)} onConfirm={onConfirm} setError={setError} - > + /> + ) } diff --git a/packages/ui/src/views/assistants/custom/DescribeMode.jsx b/packages/ui/src/views/assistants/custom/DescribeMode.jsx new file mode 100644 index 00000000000..9b3088e5117 --- /dev/null +++ b/packages/ui/src/views/assistants/custom/DescribeMode.jsx @@ -0,0 +1,697 @@ +import { cloneDeep } from 'lodash' +import { useEffect, useState, useRef } from 'react' +import { useSelector } from 'react-redux' +import PropTypes from 'prop-types' + +// Material-UI +import { IconButton, Box, Button, OutlinedInput, Stack, Typography } from '@mui/material' +import { useTheme, darken } from '@mui/material/styles' + +// Project imports +import { Dropdown } from '@/ui-component/dropdown/Dropdown' +import DocStoreInputHandler from '@/views/docstore/DocStoreInputHandler' + +// Utils +import { initNode, showHideInputParams } from '@/utils/genericHelper' + +// Const + +// Icons +import { IconArrowDown, IconArrowUp } from '@tabler/icons-react' + +// Whitelist of params to show in model config +const MODEL_PARAM_WHITELIST = ['credential', 'model', 'modelName', 'customModel', 'customModelName'] + +// Mock questions for the describe flow +const MOCK_QUESTIONS = [ + { + text: 'Which tools or integrations should your agent have access to?', + options: ['Web Search', 'Code Interpreter', 'File Upload / Retrieval', 'Something else'] + }, + { + text: 'How should the agent respond — concise and direct, or detailed and explanatory?', + options: ['Concise and direct', 'Detailed and explanatory', 'Adaptive based on query', 'Not sure'] + } +] + +const DescribeMode = ({ + selectedChatModel, + setSelectedChatModel, + chatModelsComponents, + chatModelsOptions, + handleChatModelDataChange, + setAgentName, + setCustomAssistantInstruction, + setCreationMode, + modelConfirmed, + setModelConfirmed, + generateTask, + defaultConfigResolved = true +}) => { + const theme = useTheme() + const customization = useSelector((state) => state.customization) + + const [describeInput, setDescribeInput] = useState('') + const [chatMessages, setChatMessages] = useState([]) + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) + const [isTyping, setIsTyping] = useState(false) + const [showScrollButton, setShowScrollButton] = useState(false) + + const chatEndRef = useRef(null) + const chatContainerRef = useRef(null) + const isUserNearBottom = useRef(true) + const isFirstMessage = useRef(true) + + // ==============================|| Scroll Helpers ||============================== // + + const scrollToBottom = (force = false) => { + setTimeout(() => { + if (force || isUserNearBottom.current || isFirstMessage.current) { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + isFirstMessage.current = false + } + }, 100) + } + + const handleChatScroll = () => { + const el = chatContainerRef.current + if (!el) return + const threshold = 80 + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold + isUserNearBottom.current = atBottom + setShowScrollButton(!atBottom) + } + + const handleScrollToBottomClick = () => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + setShowScrollButton(false) + isUserNearBottom.current = true + } + + // ==============================|| Chat Handlers ||============================== // + + const addBotMessage = (content, type = 'text', questionIndex) => { + setChatMessages((prev) => [...prev, { role: 'bot', content, type, questionIndex }]) + scrollToBottom() + } + + const handleDescribeSubmit = () => { + if (!describeInput.trim() || !modelConfirmed) return + const userMsg = describeInput.trim() + setChatMessages((prev) => [...prev, { role: 'user', content: userMsg }]) + setDescribeInput('') + setIsTyping(true) + scrollToBottom(true) + + const hasAskedQuestions = chatMessages.some((m) => m.type === 'question') + + if (!hasAskedQuestions) { + setTimeout(() => { + setIsTyping(false) + addBotMessage(`Great idea! I'll help you build that. I have a couple of questions to make sure I get it right.`) + setTimeout(() => { + setCurrentQuestionIndex(0) + addBotMessage(MOCK_QUESTIONS[0].text, 'question', 0) + scrollToBottom() + }, 600) + }, 1200) + } else { + setTimeout(() => { + setIsTyping(false) + const nextIdx = currentQuestionIndex + 1 + if (nextIdx < MOCK_QUESTIONS.length) { + setCurrentQuestionIndex(nextIdx) + addBotMessage(MOCK_QUESTIONS[nextIdx].text, 'question', nextIdx) + } else { + handleDescribeFinish(userMsg) + } + }, 800) + } + } + + const handleOptionSelect = (option) => { + setChatMessages((prev) => [...prev, { role: 'user', content: option }]) + setIsTyping(true) + scrollToBottom(true) + + setTimeout(() => { + setIsTyping(false) + const nextIdx = currentQuestionIndex + 1 + if (nextIdx < MOCK_QUESTIONS.length) { + setCurrentQuestionIndex(nextIdx) + addBotMessage(MOCK_QUESTIONS[nextIdx].text, 'question', nextIdx) + } else { + handleDescribeFinish(option) + } + }, 800) + } + + const handleSkipQuestion = () => { + setIsTyping(true) + scrollToBottom() + + setTimeout(() => { + setIsTyping(false) + const nextIdx = currentQuestionIndex + 1 + if (nextIdx < MOCK_QUESTIONS.length) { + setCurrentQuestionIndex(nextIdx) + addBotMessage(MOCK_QUESTIONS[nextIdx].text, 'question', nextIdx) + } else { + handleDescribeFinish() + } + }, 600) + } + + const handleDescribeFinish = () => { + addBotMessage("I've got everything I need. Setting up your agent now...") + const firstUserMsg = chatMessages.find((m) => m.role === 'user')?.content || 'New Agent' + setTimeout(() => { + setAgentName(firstUserMsg.length > 50 ? firstUserMsg.slice(0, 50) : firstUserMsg) + setCustomAssistantInstruction(firstUserMsg) + setCreationMode('manual') + }, 1500) + } + + // ==============================|| Effects ||============================== // + + // Auto-submit generate task when model is confirmed + useEffect(() => { + if (generateTask && chatMessages.length === 0) { + if (modelConfirmed) { + setTimeout(() => { + setChatMessages([{ role: 'user', content: generateTask }]) + setDescribeInput('') + setIsTyping(true) + setTimeout(() => { + setIsTyping(false) + setChatMessages((prev) => [ + ...prev, + { + role: 'bot', + content: `Great idea! I'll help you build that. I have a couple of questions to make sure I get it right.` + } + ]) + setTimeout(() => { + setCurrentQuestionIndex(0) + setChatMessages((prev) => [ + ...prev, + { role: 'bot', content: MOCK_QUESTIONS[0].text, type: 'question', questionIndex: 0 } + ]) + }, 600) + }, 1200) + }, 300) + } else { + setDescribeInput(generateTask) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [generateTask, modelConfirmed]) + + // Hide page scrollbar in describe mode + useEffect(() => { + const style = document.createElement('style') + style.id = 'hide-describe-scrollbar' + if (modelConfirmed) { + style.textContent = 'html, body { overflow: hidden !important; }' + } else { + style.textContent = ` + html, body { scrollbar-width: none !important; -ms-overflow-style: none !important; } + html::-webkit-scrollbar, body::-webkit-scrollbar { display: none !important; } + ` + } + document.head.appendChild(style) + return () => { + const el = document.getElementById('hide-describe-scrollbar') + if (el) el.remove() + } + }, [modelConfirmed]) + + // ==============================|| Model Selection Handler ||============================== // + + const handleModelSelect = (newValue) => { + if (!newValue) { + setSelectedChatModel({}) + } else { + const found = chatModelsComponents.find((c) => c.name === newValue) + if (found) { + const id = `${found.name}_0` + const cloned = cloneDeep(found) + const data = initNode(cloned, id) + setSelectedChatModel(data) + scrollToBottom(true) + } + } + } + + const handleConfirm = () => { + setModelConfirmed(true) + scrollToBottom(true) + } + + // ==============================|| Render ||============================== // + + return ( + + {/* Chat messages area — scrollable */} + + {/* Empty state — centered content with model selector */} + {chatMessages.length === 0 && ( + + + What do you want to build? + + + Describe your agent or start with a template. + + + {/* Model selector — hidden once confirmed or while default config is still resolving */} + {!modelConfirmed && defaultConfigResolved && ( + + + {!selectedChatModel?.name && ( + + + + Select a model to get started + + + )} + + + + + {/* Credential and model name fields */} + {selectedChatModel && + Object.keys(selectedChatModel).length > 0 && + showHideInputParams(selectedChatModel) + .filter( + (ip) => !ip.hidden && ip.display !== false && MODEL_PARAM_WHITELIST.includes(ip.name) + ) + .map((ip, idx) => ( + + ))} + + + For best results, use larger models such as Claude Opus 4.6, GPT-5.4, or Gemini 3.1 + + {selectedChatModel?.name && ( + + )} + + + )} + + )} + + {/* Messages */} + {chatMessages.map((msg, idx) => ( + + {msg.role === 'user' ? ( + + + {msg.content} + + + ) : msg.type === 'question' ? ( + + + + {msg.content} + + + {MOCK_QUESTIONS[msg.questionIndex]?.options.map((option, optIdx) => { + const isLastQuestion = idx === chatMessages.length - 1 + return ( + handleOptionSelect(option) : undefined} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.5, + py: 1.5, + px: 2, + borderRadius: 2, + cursor: isLastQuestion ? 'pointer' : 'default', + '&:hover': isLastQuestion + ? { + backgroundColor: customization.isDarkMode + ? 'rgba(255,255,255,0.05)' + : 'rgba(0,0,0,0.03)' + } + : {} + }} + > + + + {optIdx + 1} + + + {option} + + ) + })} + + {idx === chatMessages.length - 1 && ( + + + {currentQuestionIndex + 1} of {MOCK_QUESTIONS.length} + + + + )} + + + ) : ( + + + {msg.content} + + + )} + + ))} + + {/* Typing indicator */} + {isTyping && ( + + + {[0, 1, 2].map((i) => ( + + ))} + + + )} + +
+ + + {/* Scroll to bottom button */} + {showScrollButton && modelConfirmed && ( + + + + + + )} + + {/* Input area */} + + + setDescribeInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleDescribeSubmit() + } + }} + sx={{ + pr: 7, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none' + } + }} + /> + + + + + + + ) +} + +DescribeMode.propTypes = { + selectedChatModel: PropTypes.object, + setSelectedChatModel: PropTypes.func, + chatModelsComponents: PropTypes.array, + chatModelsOptions: PropTypes.array, + handleChatModelDataChange: PropTypes.func, + setAgentName: PropTypes.func, + setCustomAssistantInstruction: PropTypes.func, + setCreationMode: PropTypes.func, + modelConfirmed: PropTypes.bool, + setModelConfirmed: PropTypes.func, + generateTask: PropTypes.string, + defaultConfigResolved: PropTypes.bool +} + +export default DescribeMode diff --git a/packages/ui/src/views/assistants/custom/toolAgentFlow.js b/packages/ui/src/views/assistants/custom/toolAgentFlow.js deleted file mode 100644 index a39bf47bde1..00000000000 --- a/packages/ui/src/views/assistants/custom/toolAgentFlow.js +++ /dev/null @@ -1,336 +0,0 @@ -export const toolAgentFlow = { - nodes: [ - { - id: 'bufferMemory_0', - data: { - id: 'bufferMemory_0', - label: 'Buffer Memory', - version: 2, - name: 'bufferMemory', - type: 'BufferMemory', - baseClasses: ['BufferMemory', 'BaseChatMemory', 'BaseMemory'], - category: 'Memory', - description: 'Retrieve chat messages stored in database', - inputParams: [ - { - label: 'Session Id', - name: 'sessionId', - type: 'string', - description: - 'If not specified, a random id will be used. Learn more', - default: '', - additionalParams: true, - optional: true, - id: 'bufferMemory_0-input-sessionId-string' - }, - { - label: 'Memory Key', - name: 'memoryKey', - type: 'string', - default: 'chat_history', - additionalParams: true, - id: 'bufferMemory_0-input-memoryKey-string' - } - ], - inputAnchors: [], - inputs: { - sessionId: '', - memoryKey: 'chat_history' - }, - outputAnchors: [ - { - id: 'bufferMemory_0-output-bufferMemory-BufferMemory|BaseChatMemory|BaseMemory', - name: 'bufferMemory', - label: 'BufferMemory', - description: 'Retrieve chat messages stored in database', - type: 'BufferMemory | BaseChatMemory | BaseMemory' - } - ], - outputs: {} - } - }, - { - id: 'chatOpenAI_0', - data: { - id: 'chatOpenAI_0', - label: 'ChatOpenAI', - version: 8, - name: 'chatOpenAI', - type: 'ChatOpenAI', - baseClasses: ['ChatOpenAI', 'BaseChatModel', 'BaseLanguageModel', 'Runnable'], - category: 'Chat Models', - description: 'Wrapper around OpenAI large language models that use the Chat endpoint', - inputParams: [ - { - label: 'Connect Credential', - name: 'credential', - type: 'credential', - credentialNames: ['openAIApi'], - id: 'chatOpenAI_0-input-credential-credential' - }, - { - label: 'Model Name', - name: 'modelName', - type: 'asyncOptions', - loadMethod: 'listModels', - default: 'gpt-4o-mini', - id: 'chatOpenAI_0-input-modelName-asyncOptions' - }, - { - label: 'Temperature', - name: 'temperature', - type: 'number', - step: 0.1, - default: 0.9, - optional: true, - id: 'chatOpenAI_0-input-temperature-number' - }, - { - label: 'Streaming', - name: 'streaming', - type: 'boolean', - default: true, - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-streaming-boolean' - }, - { - label: 'Max Tokens', - name: 'maxTokens', - type: 'number', - step: 1, - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-maxTokens-number' - }, - { - label: 'Top Probability', - name: 'topP', - type: 'number', - step: 0.1, - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-topP-number' - }, - { - label: 'Frequency Penalty', - name: 'frequencyPenalty', - type: 'number', - step: 0.1, - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-frequencyPenalty-number' - }, - { - label: 'Presence Penalty', - name: 'presencePenalty', - type: 'number', - step: 0.1, - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-presencePenalty-number' - }, - { - label: 'Timeout', - name: 'timeout', - type: 'number', - step: 1, - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-timeout-number' - }, - { - label: 'BasePath', - name: 'basepath', - type: 'string', - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-basepath-string' - }, - { - label: 'Proxy Url', - name: 'proxyUrl', - type: 'string', - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-proxyUrl-string' - }, - { - label: 'Stop Sequence', - name: 'stopSequence', - type: 'string', - rows: 4, - optional: true, - description: 'List of stop words to use when generating. Use comma to separate multiple stop words.', - additionalParams: true, - id: 'chatOpenAI_0-input-stopSequence-string' - }, - { - label: 'Base Options', - name: 'baseOptions', - type: 'json', - optional: true, - additionalParams: true, - id: 'chatOpenAI_0-input-baseOptions-json' - }, - { - label: 'Allow Image Uploads', - name: 'allowImageUploads', - type: 'boolean', - description: - 'Allow image input. Refer to the docs for more details.', - default: false, - optional: true, - id: 'chatOpenAI_0-input-allowImageUploads-boolean' - } - ], - inputAnchors: [ - { - label: 'Cache', - name: 'cache', - type: 'BaseCache', - optional: true, - id: 'chatOpenAI_0-input-cache-BaseCache' - } - ], - inputs: { - cache: '', - modelName: 'gpt-4o-mini', - temperature: 0.9, - streaming: true, - maxTokens: '', - topP: '', - frequencyPenalty: '', - presencePenalty: '', - timeout: '', - basepath: '', - proxyUrl: '', - stopSequence: '', - baseOptions: '', - allowImageUploads: '' - }, - outputAnchors: [ - { - id: 'chatOpenAI_0-output-chatOpenAI-ChatOpenAI|BaseChatModel|BaseLanguageModel|Runnable', - name: 'chatOpenAI', - label: 'ChatOpenAI', - description: 'Wrapper around OpenAI large language models that use the Chat endpoint', - type: 'ChatOpenAI | BaseChatModel | BaseLanguageModel | Runnable' - } - ], - outputs: {} - } - }, - { - id: 'toolAgent_0', - data: { - id: 'toolAgent_0', - label: 'Tool Agent', - version: 2, - name: 'toolAgent', - type: 'AgentExecutor', - baseClasses: ['AgentExecutor', 'BaseChain', 'Runnable'], - category: 'Agents', - description: 'Agent that uses Function Calling to pick the tools and args to call', - inputParams: [ - { - label: 'System Message', - name: 'systemMessage', - type: 'string', - default: 'You are a helpful AI assistant.', - description: 'If Chat Prompt Template is provided, this will be ignored', - rows: 4, - optional: true, - additionalParams: true, - id: 'toolAgent_0-input-systemMessage-string' - }, - { - label: 'Max Iterations', - name: 'maxIterations', - type: 'number', - optional: true, - additionalParams: true, - id: 'toolAgent_0-input-maxIterations-number' - } - ], - inputAnchors: [ - { - label: 'Tools', - name: 'tools', - type: 'Tool', - list: true, - id: 'toolAgent_0-input-tools-Tool' - }, - { - label: 'Memory', - name: 'memory', - type: 'BaseChatMemory', - id: 'toolAgent_0-input-memory-BaseChatMemory' - }, - { - label: 'Tool Calling Chat Model', - name: 'model', - type: 'BaseChatModel', - description: - 'Only compatible with models that are capable of function calling: ChatOpenAI, ChatMistral, ChatAnthropic, ChatGoogleGenerativeAI, ChatVertexAI, GroqChat', - id: 'toolAgent_0-input-model-BaseChatModel' - }, - { - label: 'Chat Prompt Template', - name: 'chatPromptTemplate', - type: 'ChatPromptTemplate', - description: 'Override existing prompt with Chat Prompt Template. Human Message must includes {input} variable', - optional: true, - id: 'toolAgent_0-input-chatPromptTemplate-ChatPromptTemplate' - }, - { - label: 'Input Moderation', - description: 'Detect text that could generate harmful output and prevent it from being sent to the language model', - name: 'inputModeration', - type: 'Moderation', - optional: true, - list: true, - id: 'toolAgent_0-input-inputModeration-Moderation' - } - ], - inputs: { - tools: [], - memory: '{{bufferMemory_0.data.instance}}', - model: '{{chatOpenAI_0.data.instance}}', - chatPromptTemplate: '', - systemMessage: 'You are helpful assistant', - inputModeration: '', - maxIterations: '' - }, - outputAnchors: [ - { - id: 'toolAgent_0-output-toolAgent-AgentExecutor|BaseChain|Runnable', - name: 'toolAgent', - label: 'AgentExecutor', - description: 'Agent that uses Function Calling to pick the tools and args to call', - type: 'AgentExecutor | BaseChain | Runnable' - } - ], - outputs: {} - } - } - ], - edges: [ - { - source: 'bufferMemory_0', - sourceHandle: 'bufferMemory_0-output-bufferMemory-BufferMemory|BaseChatMemory|BaseMemory', - target: 'toolAgent_0', - targetHandle: 'toolAgent_0-input-memory-BaseChatMemory', - type: 'buttonedge', - id: 'bufferMemory_0-bufferMemory_0-output-bufferMemory-BufferMemory|BaseChatMemory|BaseMemory-toolAgent_0-toolAgent_0-input-memory-BaseChatMemory' - }, - { - source: 'chatOpenAI_0', - sourceHandle: 'chatOpenAI_0-output-chatOpenAI-ChatOpenAI|BaseChatModel|BaseLanguageModel|Runnable', - target: 'toolAgent_0', - targetHandle: 'toolAgent_0-input-model-BaseChatModel', - type: 'buttonedge', - id: 'chatOpenAI_0-chatOpenAI_0-output-chatOpenAI-ChatOpenAI|BaseChatModel|BaseLanguageModel|Runnable-toolAgent_0-toolAgent_0-input-model-BaseChatModel' - } - ] -} diff --git a/packages/ui/src/views/assistants/index.jsx b/packages/ui/src/views/assistants/index.jsx index bd7af08a56c..6590e71ce25 100644 --- a/packages/ui/src/views/assistants/index.jsx +++ b/packages/ui/src/views/assistants/index.jsx @@ -14,16 +14,17 @@ import { IconRobotFace, IconBrandOpenai } from '@tabler/icons-react' const cards = [ { - title: 'Custom Assistant', - description: 'Create custom assistant using your choice of LLMs', + title: 'Agent', + description: 'Custom Assistant has been moved to Agents. You can now find it under the Agents section in the sidebar.', icon: , iconText: 'Custom', - gradient: 'linear-gradient(135deg, #fff8e14e 0%, #ffcc802f 100%)' + gradient: 'linear-gradient(135deg, #fff8e14e 0%, #ffcc802f 100%)', + deprecated: true, + deprecatedLabel: 'Moved to Agents' }, { title: 'OpenAI Assistant', - description: - 'Create assistant using OpenAI Assistant API. This option is being deprecated; consider using Custom Assistant instead.', + description: 'Create assistant using OpenAI Assistant API. This option is being deprecated; consider using Agent instead.', icon: , iconText: 'OpenAI', gradient: 'linear-gradient(135deg, #c9ffd85f 0%, #a0f0b567 100%)', @@ -59,7 +60,7 @@ const FeatureCards = () => { const customization = useSelector((state) => state.customization) const onCardClick = (index) => { - if (index === 0) navigate('/assistants/custom') + if (index === 0) navigate('/agents') if (index === 1) navigate('/assistants/openai') } @@ -100,6 +101,7 @@ const FeatureCards = () => { {card.icon} {card.iconText} + {card.deprecated && } {card.deprecating && }

{card.title}

diff --git a/packages/ui/src/views/assistants/openai/OpenAIAssistantLayout.jsx b/packages/ui/src/views/assistants/openai/OpenAIAssistantLayout.jsx index 865245c6bc2..bee0a6b8a26 100644 --- a/packages/ui/src/views/assistants/openai/OpenAIAssistantLayout.jsx +++ b/packages/ui/src/views/assistants/openai/OpenAIAssistantLayout.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' // material-ui -import { Box, Stack, Skeleton } from '@mui/material' +import { Box, Fade, Skeleton, Stack } from '@mui/material' // project imports import MainCard from '@/ui-component/cards/MainCard' @@ -113,70 +113,72 @@ const OpenAIAssistantLayout = () => { {error ? ( ) : ( - - navigate(-1)} - > - } - sx={{ borderRadius: 2, height: 40 }} + + + navigate(-1)} > - Load - - } - > - Add - - - {isLoading ? ( - - - - - - ) : ( - - {getAllAssistantsApi.data && - getAllAssistantsApi.data?.filter(filterAssistants).map((data, index) => ( - edit(data)} - /> - ))} - - )} - {!isLoading && (!getAllAssistantsApi.data || getAllAssistantsApi.data.length === 0) && ( - - - AssistantEmptySVG + } + sx={{ borderRadius: 2, height: 40 }} + > + Load + + } + > + Add + + + {isLoading ? ( + + + + -
No OpenAI Assistants Added Yet
-
- )} -
+ ) : ( + + {getAllAssistantsApi.data && + getAllAssistantsApi.data?.filter(filterAssistants).map((data, index) => ( + edit(data)} + /> + ))} + + )} + {!isLoading && (!getAllAssistantsApi.data || getAllAssistantsApi.data.length === 0) && ( + + + AssistantEmptySVG + +
No OpenAI Assistants Added Yet
+
+ )} + + )} { + // Strip globally-hidden nodes before any category grouping + nodes = nodes.filter((nd) => !blacklistNodeNames.includes(nd.name)) if (isAgentCanvas) { const accordianCategories = {} const result = nodes.reduce(function (r, a) { diff --git a/packages/ui/src/views/canvas/CanvasHeader.jsx b/packages/ui/src/views/canvas/CanvasHeader.jsx index 85dccf45134..f3a028083fd 100644 --- a/packages/ui/src/views/canvas/CanvasHeader.jsx +++ b/packages/ui/src/views/canvas/CanvasHeader.jsx @@ -139,7 +139,7 @@ const CanvasHeader = ({ chatflow, isAgentCanvas, isAgentflowV2, handleSaveFlow, } else if (setting === 'exportChatflow') { try { const flowData = JSON.parse(chatflow.flowData) - let dataStr = JSON.stringify(generateExportFlowData(flowData), null, 2) + let dataStr = JSON.stringify(generateExportFlowData(flowData, chatflow.type), null, 2) //let dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr) const blob = new Blob([dataStr], { type: 'application/json' }) const dataUri = URL.createObjectURL(blob) diff --git a/packages/ui/src/views/canvas/index.jsx b/packages/ui/src/views/canvas/index.jsx index 8835706ecf3..602160d796e 100644 --- a/packages/ui/src/views/canvas/index.jsx +++ b/packages/ui/src/views/canvas/index.jsx @@ -166,6 +166,27 @@ const Canvas = () => { const handleLoadFlow = (file) => { try { const flowData = JSON.parse(file) + const expectedType = isAgentCanvas ? 'MULTIAGENT' : 'CHATFLOW' + if (flowData.type && flowData.type !== expectedType) { + enqueueSnackbar({ + message: `Invalid file: expected ${expectedType} type but got ${flowData.type}`, + options: { + key: new Date().getTime() + Math.random(), + variant: 'error', + persist: true, + action: (key) => ( + + ) + } + }) + return + } + delete flowData.type const nodes = flowData.nodes || [] setNodes(nodes) diff --git a/packages/ui/src/views/chatbot/index.jsx b/packages/ui/src/views/chatbot/index.jsx index 1bf8b723d0b..0a92134b6ef 100644 --- a/packages/ui/src/views/chatbot/index.jsx +++ b/packages/ui/src/views/chatbot/index.jsx @@ -44,7 +44,7 @@ const ChatbotFull = () => { const chatflowType = chatflowData.type if (chatflowData.chatbotConfig) { let parsedConfig = {} - if (chatflowType === 'MULTIAGENT' || chatflowType === 'AGENTFLOW') { + if (chatflowType === 'MULTIAGENT' || chatflowType === 'AGENTFLOW' || chatflowType === 'AGENT') { parsedConfig.showAgentMessages = true } @@ -63,7 +63,7 @@ const ChatbotFull = () => { setChatbotTheme(parsedConfig) setChatbotOverrideConfig({}) } - } else if (chatflowType === 'MULTIAGENT' || chatflowType === 'AGENTFLOW') { + } else if (chatflowType === 'MULTIAGENT' || chatflowType === 'AGENTFLOW' || chatflowType === 'AGENT') { setChatbotTheme({ showAgentMessages: true }) } } diff --git a/packages/ui/src/views/chatflows/index.jsx b/packages/ui/src/views/chatflows/index.jsx index 3a094b54e4f..ec9bbb428db 100644 --- a/packages/ui/src/views/chatflows/index.jsx +++ b/packages/ui/src/views/chatflows/index.jsx @@ -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 { Box, Fade, Skeleton, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material' import { useTheme } from '@mui/material/styles' // project imports @@ -134,102 +134,104 @@ const Chatflows = () => { {error ? ( ) : ( - - - + + - - - - + + + + + + + } + sx={{ borderRadius: 2, height: 40 }} > - - - - } - sx={{ borderRadius: 2, height: 40 }} - > - Add New - - - - {isLoading && ( - - - - - - )} - {!isLoading && total > 0 && ( - <> - {!view || view === 'card' ? ( - - {getAllChatflowsApi.data?.data?.filter(filterFlows).map((data, index) => ( - goToCanvas(data)} data={data} images={images[data.id]} /> - ))} - - ) : ( - - )} - {/* Pagination and Page Size Controls */} - - - )} - {!isLoading && (!getAllChatflowsApi.data?.data || getAllChatflowsApi.data?.data.length === 0) && ( - - - WorkflowEmptySVG + Add New + + + + {isLoading && ( + + + + -
No Chatflows Yet
-
- )} -
+ )} + {!isLoading && total > 0 && ( + <> + {!view || view === 'card' ? ( + + {getAllChatflowsApi.data?.data?.filter(filterFlows).map((data, index) => ( + goToCanvas(data)} data={data} images={images[data.id]} /> + ))} + + ) : ( + + )} + {/* Pagination and Page Size Controls */} + + + )} + {!isLoading && (!getAllChatflowsApi.data?.data || getAllChatflowsApi.data?.data.length === 0) && ( + + + WorkflowEmptySVG + +
No Chatflows Yet
+
+ )} + + )} diff --git a/packages/ui/src/views/credentials/index.jsx b/packages/ui/src/views/credentials/index.jsx index 3ab68e34bc9..57fda740da8 100644 --- a/packages/ui/src/views/credentials/index.jsx +++ b/packages/ui/src/views/credentials/index.jsx @@ -9,6 +9,7 @@ import { tableCellClasses } from '@mui/material/TableCell' import { Button, Box, + Fade, Skeleton, Stack, Table, @@ -241,199 +242,204 @@ const Credentials = () => { {error ? ( ) : ( - - - } + + + - Add Credential - - - {!isLoading && credentials.length <= 0 ? ( - - - CredentialEmptySVG - -
No Credentials Yet
-
- ) : ( - - - - - Name - Last Updated - Created - - - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {credentials.filter(filterCredentials).map((credential, index) => ( - - - - - {credential.credentialName} { - e.target.onerror = null - e.target.style.padding = '5px' - e.target.src = keySVG - }} - /> - - {credential.name} - + } + > + Add Credential + + + {!isLoading && credentials.length <= 0 ? ( + + + CredentialEmptySVG + +
No Credentials Yet
+
+ ) : ( + +
+ + + Name + Last Updated + Created + + + + + + + {isLoading ? ( + <> + + + - {moment(credential.updatedDate).format('MMMM Do, YYYY HH:mm:ss')} + - {moment(credential.createdDate).format('MMMM Do, YYYY HH:mm:ss')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - {!credential.shared && ( - <> - - share(credential)} - > - - - - - edit(credential)} - > - - - - - deleteCredential(credential)} - > - - - - - )} - {credential.shared && ( - <> - Shared Credential - - )} - ))} - - )} - -
-
- )} -
+ + ) : ( + <> + {credentials.filter(filterCredentials).map((credential, index) => ( + + + + + {credential.credentialName} { + e.target.onerror = null + e.target.style.padding = '5px' + e.target.src = keySVG + }} + /> + + {credential.name} + + + + {moment(credential.updatedDate).format('MMMM Do, YYYY HH:mm:ss')} + + + {moment(credential.createdDate).format('MMMM Do, YYYY HH:mm:ss')} + + {!credential.shared && ( + <> + + share(credential)} + > + + + + + edit(credential)} + > + + + + + deleteCredential(credential)} + > + + + + + )} + {credential.shared && ( + <> + Shared Credential + + )} + + ))} + + )} + + + + )} + + )} { {error ? ( ) : ( - - - } + + + - Add New - - - {!isLoading && datasets.length <= 0 ? ( - - - empty_datasetSVG - -
No Datasets Yet
-
- ) : ( - <> - } > - - - - Name - Description - Rows - Last Updated - - - - - - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {datasets.filter(filterDatasets).map((ds, index) => ( - - goToRows(ds)} component='th' scope='row'> - {ds.name} - - goToRows(ds)} - style={{ wordWrap: 'break-word', flexWrap: 'wrap', width: '40%' }} - > - {truncateString(ds?.description, 200)} - - goToRows(ds)}>{ds?.rowCount} - goToRows(ds)}> - {moment(ds.updatedDate).format('MMMM Do YYYY, hh:mm A')} - + Add New + + + {!isLoading && datasets.length <= 0 ? ( + + + empty_datasetSVG + +
No Datasets Yet
+
+ ) : ( + <> + +
+ + + Name + Description + Rows + Last Updated + + + + + + + + + + {isLoading ? ( + <> + + + + + + + + + + + + + - - edit(ds)}> - - - + - - deleteDataset(ds)} - > - - - + + + + + + + + + + + + + + + + + + + + + - ))} - - )} - -
-
- {/* Pagination and Page Size Controls */} - - - )} -
+ + ) : ( + <> + {datasets.filter(filterDatasets).map((ds, index) => ( + + goToRows(ds)} component='th' scope='row'> + {ds.name} + + goToRows(ds)} + style={{ wordWrap: 'break-word', flexWrap: 'wrap', width: '40%' }} + > + {truncateString(ds?.description, 200)} + + goToRows(ds)}>{ds?.rowCount} + goToRows(ds)}> + {moment(ds.updatedDate).format('MMMM Do YYYY, hh:mm A')} + + + + edit(ds)}> + + + + + + + deleteDataset(ds)} + > + + + + + + ))} + + )} + + + + {/* Pagination and Page Size Controls */} + + + )} + + )} { {error ? ( ) : ( - - - {hasDocStores && ( - - - - - - - - - )} - } - id='btn_createVariable' + + + - Add New - - - {!hasDocStores ? ( - - - doc_store_empty - -
No Document Stores Created Yet
-
- ) : ( - - {!view || view === 'card' ? ( - - {docStores?.filter(filterDocStores).map((data) => ( - - goToDocumentStore(data.id)} - /> - {canManageDocumentStore && ( - handleActionMenuOpen(event, data)} - > - - - )} - - ))} - - ) : ( - goToDocumentStore(row.id)} - showActions={canManageDocumentStore} - onActionMenuClick={handleActionMenuOpen} - actionButtonSx={getDocStoreActionButtonSx(theme)} - /> + {hasDocStores && ( + + + + + + + + )} - {/* Pagination and Page Size Controls */} - - - )} -
+ } + id='btn_createVariable' + > + Add New + + + {!hasDocStores ? ( + + + doc_store_empty + +
No Document Stores Created Yet
+
+ ) : ( + + {!view || view === 'card' ? ( + + {docStores?.filter(filterDocStores).map((data) => ( + + goToDocumentStore(data.id)} + /> + {canManageDocumentStore && ( + handleActionMenuOpen(event, data)} + > + + + )} + + ))} + + ) : ( + goToDocumentStore(row.id)} + showActions={canManageDocumentStore} + onActionMenuClick={handleActionMenuOpen} + actionButtonSx={getDocStoreActionButtonSx(theme)} + /> + )} + {/* Pagination and Page Size Controls */} + + + )} + + )} {showDialog && ( { const getAllEvaluatorsApi = useApi(evaluatorsApi.getAllEvaluators) const getNodesByCategoryApi = useApi(nodesApi.getNodesByCategory) const getModelsApi = useApi(nodesApi.executeNodeLoadMethod) - const getAssistantsApi = useApi(assistantsApi.getAllAssistants) + const getAllAgentsApi = useApi(chatflowsApi.getAllAgentflows) const [chatflow, setChatflow] = useState([]) const [dataset, setDataset] = useState('') @@ -227,7 +226,7 @@ const CreateEvaluationDialog = ({ show, dialogProps, onCancel, onConfirm }) => { getNodesByCategoryApi.request('Chat Models') if (flows.length === 0) { getAllChatflowsApi.request() - getAssistantsApi.request('CUSTOM') + getAllAgentsApi.request('AGENT') getAllAgentflowsApi.request('AGENTFLOW') } if (datasets.length === 0) { @@ -238,18 +237,18 @@ const CreateEvaluationDialog = ({ show, dialogProps, onCancel, onConfirm }) => { }, []) useEffect(() => { - if (getAllAgentflowsApi.data && getAllChatflowsApi.data && getAssistantsApi.data) { + if (getAllAgentflowsApi.data && getAllChatflowsApi.data && getAllAgentsApi.data) { try { const agentFlows = populateFlowNames(getAllAgentflowsApi.data, 'Agentflow v2') const chatFlows = populateFlowNames(getAllChatflowsApi.data, 'Chatflow') - const assistants = populateAssistants(getAssistantsApi.data) - setFlows([...agentFlows, ...chatFlows, ...assistants]) - setFlowTypes(['Agentflow v2', 'Chatflow', 'Custom Assistant']) + const agents = populateFlowNames(getAllAgentsApi.data, 'Agent') + setFlows([...agentFlows, ...chatFlows, ...agents]) + setFlowTypes(['Agentflow v2', 'Chatflow', 'Agent']) } catch (e) { console.error(e) } } - }, [getAllAgentflowsApi.data, getAllChatflowsApi.data, getAssistantsApi.data]) + }, [getAllAgentflowsApi.data, getAllChatflowsApi.data, getAllAgentsApi.data]) useEffect(() => { if (getNodesByCategoryApi.data) { @@ -369,20 +368,6 @@ const CreateEvaluationDialog = ({ show, dialogProps, onCancel, onConfirm }) => { return flowNames } - const populateAssistants = (assistants) => { - let assistantNames = [] - for (let i = 0; i < assistants.length; i += 1) { - const assistant = assistants[i] - assistantNames.push({ - label: JSON.parse(assistant.details).name || '', - name: assistant.id, - type: 'Custom Assistant', - description: 'Custom Assistant' - }) - } - return assistantNames - } - const component = show ? ( { onChange={onChangeFlowType} />{' '} Agentflows (v2) - {' '} - Custom Assistants + {' '} + Agents
} label={ item.metrics[index]?.chain - ? 'Chain Latency: ' + item.metrics[index]?.chain - : 'Chain Latency: N/A' + ? 'Flow Latency: ' + item.metrics[index]?.chain + : 'Flow Latency: N/A' } sx={{ mr: 1, mb: 1 }} /> diff --git a/packages/ui/src/views/evaluations/EvaluationResult.jsx b/packages/ui/src/views/evaluations/EvaluationResult.jsx index 6fdde95b759..ae3ad861dab 100644 --- a/packages/ui/src/views/evaluations/EvaluationResult.jsx +++ b/packages/ui/src/views/evaluations/EvaluationResult.jsx @@ -336,7 +336,8 @@ const EvalEvaluationRows = () => { case 'Chatflow': return '/canvas/' + evaluation.chatflowId[index] case 'Custom Assistant': - return '/assistants/custom/' + evaluation.chatflowId[index] + case 'Agent': + return '/agents/' + evaluation.chatflowId[index] case 'Agentflow v2': return '/v2/agentcanvas/' + evaluation.chatflowId[index] } @@ -360,6 +361,7 @@ const EvalEvaluationRows = () => { case 'Chatflow': return case 'Custom Assistant': + case 'Agent': return case 'Agentflow v2': return @@ -486,8 +488,9 @@ const EvalEvaluationRows = () => { window.open( chatflow.chatflowType === 'Chatflow' ? '/canvas/' + chatflow.chatflowId - : chatflow.chatflowType === 'Custom Assistant' - ? '/assistants/custom/' + chatflow.chatflowId + : chatflow.chatflowType === 'Custom Assistant' || + chatflow.chatflowType === 'Agent' + ? '/agents/' + chatflow.chatflowId : '/v2/agentcanvas/' + chatflow.chatflowId, '_blank' ) @@ -869,9 +872,9 @@ const EvalEvaluationRows = () => { icon={} label={ item.metrics[index]?.chain - ? 'Chain Latency: ' + + ? 'Flow Latency: ' + item.metrics[index]?.chain - : 'Chain Latency: N/A' + : 'Flow Latency: N/A' } sx={{ mr: 1, mb: 1 }} /> diff --git a/packages/ui/src/views/evaluations/EvaluationResultSideDrawer.jsx b/packages/ui/src/views/evaluations/EvaluationResultSideDrawer.jsx index c415fb983a5..a5a95b1092a 100644 --- a/packages/ui/src/views/evaluations/EvaluationResultSideDrawer.jsx +++ b/packages/ui/src/views/evaluations/EvaluationResultSideDrawer.jsx @@ -45,6 +45,7 @@ const EvaluationResultSideDrawer = ({ show, dialogProps, onClickFunction }) => { case 'Chatflow': return case 'Custom Assistant': + case 'Agent': return case 'Agentflow v2': return @@ -164,7 +165,7 @@ const EvaluationResultSideDrawer = ({ show, dialogProps, onClickFunction }) => { )} {dialogProps.data.metrics[index]?.retriever && ( diff --git a/packages/ui/src/views/evaluations/index.jsx b/packages/ui/src/views/evaluations/index.jsx index 85a2d6c66b1..043b7a3d3cb 100644 --- a/packages/ui/src/views/evaluations/index.jsx +++ b/packages/ui/src/views/evaluations/index.jsx @@ -13,6 +13,7 @@ import { Button, Chip, Collapse, + Fade, IconButton, Paper, Stack, @@ -297,198 +298,202 @@ const EvalsEvaluation = () => { {error ? ( ) : ( - - - + + + - {autoRefresh ? : } - - - - - } - > - New Evaluation - - - {selected.length > 0 && ( - } - > - Delete {selected.length} {selected.length === 1 ? 'evaluation' : 'evaluations'} - - )} - {!isTableLoading && rows.length <= 0 ? ( - - - empty_evalSVG - -
No Evaluations Yet
-
- ) : ( - <> - - - - - - item?.latestEval) || []).length} - onChange={onSelectAllClick} - inputProps={{ - 'aria-label': 'select all' - }} - /> - - - Name - Latest Version - Average Metrics - Last Evaluated - Flow(s) - Dataset - - - - - {isTableLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {rows - .filter((item) => item?.latestEval) - .map((item, index) => ( - row.name === item.name)} - item={item} - key={index} - theme={theme} - selected={selected} - customization={customization} - onRefresh={onRefresh} - handleSelect={handleSelect} - /> - ))} - - )} - -
-
- {/* Pagination and Page Size Controls */} - - - )} -
+ {autoRefresh ? : } +
+ + + + } + > + New Evaluation + +
+ {selected.length > 0 && ( + } + > + Delete {selected.length} {selected.length === 1 ? 'evaluation' : 'evaluations'} + + )} + {!isTableLoading && rows.length <= 0 ? ( + + + empty_evalSVG + +
No Evaluations Yet
+
+ ) : ( + <> + + + + + + item?.latestEval) || []).length + } + onChange={onSelectAllClick} + inputProps={{ + 'aria-label': 'select all' + }} + /> + + + Name + Latest Version + Average Metrics + Last Evaluated + Flow(s) + Dataset + + + + + {isTableLoading ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> + {rows + .filter((item) => item?.latestEval) + .map((item, index) => ( + row.name === item.name)} + item={item} + key={index} + theme={theme} + selected={selected} + customization={customization} + onRefresh={onRefresh} + handleSelect={handleSelect} + /> + ))} + + )} + +
+
+ {/* Pagination and Page Size Controls */} + + + )} +
+ )} {showNewEvaluationDialog && ( diff --git a/packages/ui/src/views/evaluators/evaluatorConstant.js b/packages/ui/src/views/evaluators/evaluatorConstant.js index 79272cf0f05..31d1c3f1d4e 100644 --- a/packages/ui/src/views/evaluators/evaluatorConstant.js +++ b/packages/ui/src/views/evaluators/evaluatorConstant.js @@ -80,9 +80,9 @@ export const evaluators = [ }, { type: 'numeric', - label: 'Chatflow Latency', + label: 'Flow Latency', name: 'chain', - description: 'Actual time spent in executing the chatflow (milliseconds).' + description: 'Actual time spent in executing the flow (milliseconds).' }, { type: 'numeric', diff --git a/packages/ui/src/views/evaluators/index.jsx b/packages/ui/src/views/evaluators/index.jsx index f1738f92303..c8901e39915 100644 --- a/packages/ui/src/views/evaluators/index.jsx +++ b/packages/ui/src/views/evaluators/index.jsx @@ -2,7 +2,21 @@ import { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' // material-ui -import { Chip, Skeleton, Box, Stack, TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, Button } from '@mui/material' +import { + Box, + Button, + Chip, + Fade, + Paper, + Skeleton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow +} from '@mui/material' import { useTheme } from '@mui/material/styles' // project imports @@ -178,385 +192,387 @@ const Evaluators = () => { {error ? ( ) : ( - - - } + + + - New Evaluator - - - {!isLoading && evaluators.length <= 0 ? ( - - - empty_evaluatorSVG - -
No Evaluators Yet
-
- ) : ( - <> - } > - - - - Type - Name - Details - Last Updated - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {evaluators.filter(filterDatasets).map((ds, index) => ( - <> - - edit(ds)}> - {ds?.type === 'numeric' && ( - - } - label='Numeric' - variant='outlined' - /> - - )} - {ds?.type === 'text' && ( - - } - label='Text Based' - variant='outlined' - /> - - )} - {ds?.type === 'json' && ( - - } - label='JSON Based' - variant='outlined' - /> - - )} - {ds?.type === 'llm' && ( - - } - label='LLM Based' - variant='outlined' - /> - - )} - - edit(ds)} component='th' scope='row'> - {ds.name} - - edit(ds)}> - {ds?.type === 'numeric' && ( - + + {!isLoading && evaluators.length <= 0 ? ( + + + empty_evaluatorSVG + +
No Evaluators Yet
+
+ ) : ( + <> + +
+ + + Type + Name + Details + Last Updated + + + + + {isLoading ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> + {evaluators.filter(filterDatasets).map((ds, index) => ( + <> + + edit(ds)}> + {ds?.type === 'numeric' && ( + + } + label='Numeric' + variant='outlined' + /> + + )} + {ds?.type === 'text' && ( + + } + label='Text Based' + variant='outlined' + /> + + )} + {ds?.type === 'json' && ( + + } + label='JSON Based' + variant='outlined' + /> + + )} + {ds?.type === 'llm' && ( + + } + label='LLM Based' + variant='outlined' + /> + + )} + + edit(ds)} component='th' scope='row'> + {ds.name} + + edit(ds)}> + {ds?.type === 'numeric' && ( + + + Measure:{' '} + { + [ + ...evaluatorsOptions, + ...numericOperators + ].find((item) => item.name === ds?.measure) + ?.label + } + + } + /> + + Operator:{' '} + { + [ + ...evaluatorsOptions, + ...numericOperators + ].find((item) => item.name === ds?.operator) + ?.label + } + + } + /> + + Value: {ds?.value} + + } + /> + + )} + {ds?.type === 'text' && ( + + + Operator:{' '} + { + [ + ...evaluatorsOptions, + ...numericOperators + ].find((item) => item.name === ds?.operator) + ?.label + } + + } + /> + + Value: {ds?.value} + + } + /> + + )} + {ds?.type === 'json' && ( + + + Operator:{' '} + { + [...evaluatorsOptions].find( + (item) => item.name === ds?.operator + )?.label + } + + } + /> + + )} + {ds?.type === 'llm' && ( + + + Prompt: {truncateString(ds?.prompt, 100)} + + } + /> + + Output Schema Elements:{' '} + {ds?.outputSchema.length > 0 + ? ds?.outputSchema + .map((item) => item.property) + .join(', ') + : 'None'} + + } + /> + + )} + + edit(ds)}> + {moment(ds.updatedDate).format('MMMM Do YYYY, hh:mm A')} + + + deleteEvaluator(ds)} > - - Measure:{' '} - { - [ - ...evaluatorsOptions, - ...numericOperators - ].find((item) => item.name === ds?.measure) - ?.label - } - - } - /> - - Operator:{' '} - { - [ - ...evaluatorsOptions, - ...numericOperators - ].find((item) => item.name === ds?.operator) - ?.label - } - - } - /> - - Value: {ds?.value} - - } - /> - - )} - {ds?.type === 'text' && ( - - - Operator:{' '} - { - [ - ...evaluatorsOptions, - ...numericOperators - ].find((item) => item.name === ds?.operator) - ?.label - } - - } - /> - - Value: {ds?.value} - - } - /> - - )} - {ds?.type === 'json' && ( - - - Operator:{' '} - { - [...evaluatorsOptions].find( - (item) => item.name === ds?.operator - )?.label - } - - } - /> - - )} - {ds?.type === 'llm' && ( - - - Prompt: {truncateString(ds?.prompt, 100)} - - } - /> - - Output Schema Elements:{' '} - {ds?.outputSchema.length > 0 - ? ds?.outputSchema - .map((item) => item.property) - .join(', ') - : 'None'} - - } - /> - - )} - - edit(ds)}> - {moment(ds.updatedDate).format('MMMM Do YYYY, hh:mm A')} - - - deleteEvaluator(ds)} - > - - - - - - ))} - - )} - -
-
- {/* Pagination and Page Size Controls */} - - - )} -
+ + + + + + ))} + + )} + + +
+ {/* Pagination and Page Size Controls */} + + + )} + + )} {showEvaluatorDialog && ( diff --git a/packages/ui/src/views/files/index.jsx b/packages/ui/src/views/files/index.jsx index e5b952c838a..0e87c0d6f40 100644 --- a/packages/ui/src/views/files/index.jsx +++ b/packages/ui/src/views/files/index.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' // material-ui -import { Box, Button, Stack } from '@mui/material' +import { Box, Button, Fade, Stack } from '@mui/material' // project imports import MainCard from '@/ui-component/cards/MainCard' @@ -126,22 +126,24 @@ const Files = () => { {error ? ( ) : ( - - - - {!isLoading && (!getAllFilesApi.data || getAllFilesApi.data.length === 0) && ( - - - WorkflowEmptySVG - -
No Files Yet
-
- )} -
+ + + + + {!isLoading && (!getAllFilesApi.data || getAllFilesApi.data.length === 0) && ( + + + WorkflowEmptySVG + +
No Files Yet
+
+ )} +
+
)} diff --git a/packages/ui/src/views/marketplaces/index.jsx b/packages/ui/src/views/marketplaces/index.jsx index 1f364761414..884ad533e95 100644 --- a/packages/ui/src/views/marketplaces/index.jsx +++ b/packages/ui/src/views/marketplaces/index.jsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { useDispatch } from 'react-redux' // material-ui @@ -8,6 +8,7 @@ import { Box, Stack, Badge, + Fade, ToggleButton, InputLabel, FormControl, @@ -61,7 +62,7 @@ import { gridSpacing } from '@/store/constant' import { useError } from '@/store/context/ErrorContext' const badges = ['POPULAR', 'NEW'] -const types = ['Chatflow', 'AgentflowV2', 'Tool'] +const types = ['Chatflow', 'AgentflowV2', 'Agent', 'Tool'] const framework = ['Langchain', 'LlamaIndex'] const MenuProps = { PaperProps: { @@ -75,6 +76,7 @@ const MenuProps = { const Marketplace = () => { const navigate = useNavigate() + const location = useLocation() const dispatch = useDispatch() useNotifier() @@ -96,7 +98,7 @@ const Marketplace = () => { const [view, setView] = React.useState(localStorage.getItem('mpDisplayStyle') || 'card') const [search, setSearch] = useState('') const [badgeFilter, setBadgeFilter] = useState([]) - const [typeFilter, setTypeFilter] = useState([]) + const [typeFilter, setTypeFilter] = useState(location.state?.typeFilter || []) const [frameworkFilter, setFrameworkFilter] = useState([]) const getAllCustomTemplatesApi = useApi(marketplacesApi.getAllCustomTemplates) @@ -342,7 +344,9 @@ const Marketplace = () => { } const goToCanvas = (selectedChatflow) => { - if (selectedChatflow.type === 'AgentflowV2') { + if (selectedChatflow.type === 'Agent') { + navigate(`/marketplace/agents/${selectedChatflow.id}`, { state: { templateData: selectedChatflow } }) + } else if (selectedChatflow.type === 'AgentflowV2') { navigate(`/v2/marketplace/${selectedChatflow.id}`, { state: selectedChatflow }) } else { navigate(`/marketplace/${selectedChatflow.id}`, { state: selectedChatflow }) @@ -471,273 +475,292 @@ const Marketplace = () => { {error ? ( ) : ( - - - + + + + + Tag + + + + + + Type + + + + + + Framework + + + + + } + onSearchChange={onSearchChange} + search={true} + searchPlaceholder='Search Name/Description/Node' + title='Marketplace' + description='Explore and use pre-built templates' + > + + - - Tag - - - - + + - - Type - - - - + + + + {hasPermission('templates:marketplace') && hasPermission('templates:custom') && ( + + + + + + setSelectedUsecases(newValue)} + disableCloseOnSelect + getOptionLabel={(option) => option} + isOptionEqualToValue={(option, value) => option === value} + renderOption={(props, option, { selected }) => { + const isDisabled = eligibleUsecases.length > 0 && !eligibleUsecases.includes(option) + + return ( +
  • + + +
  • + ) + }} + renderInput={(params) => } sx={{ - borderRadius: 2, - display: 'flex', - flexDirection: 'column', - justifyContent: 'end', - minWidth: 120 + width: 300 }} - > - - Framework - - - - - } - onSearchChange={onSearchChange} - search={true} - searchPlaceholder='Search Name/Description/Node' - title='Marketplace' - description='Explore and use pre-built templates' - > - - - - - - - - - - {hasPermission('templates:marketplace') && hasPermission('templates:custom') && ( - - - - - - setSelectedUsecases(newValue)} - disableCloseOnSelect - getOptionLabel={(option) => option} - isOptionEqualToValue={(option, value) => option === value} - renderOption={(props, option, { selected }) => { - const isDisabled = eligibleUsecases.length > 0 && !eligibleUsecases.includes(option) - - return ( -
  • - - -
  • - ) - }} - renderInput={(params) => } - sx={{ - width: 300 - }} - limitTags={2} - renderTags={(value, getTagProps) => { - const totalTags = value.length - const limitTags = 2 - - return ( - <> - {value.slice(0, limitTags).map((option, index) => ( - - ))} + limitTags={2} + renderTags={(value, getTagProps) => { + const totalTags = value.length + const limitTags = 2 + + return ( + <> + {value.slice(0, limitTags).map((option, index) => ( + + ))} - {totalTags > limitTags && ( - - {value.slice(limitTags).map((item, i) => ( -
  • {item}
  • - ))} - - } - placement='top' - > - +{totalTags - limitTags} -
    - )} - - ) - }} - slotProps={{ - paper: { - sx: { - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)' + {totalTags > limitTags && ( + + {value.slice(limitTags).map((item, i) => ( +
  • {item}
  • + ))} + + } + placement='top' + > + +{totalTags - limitTags} +
    + )} + + ) + }} + slotProps={{ + paper: { + sx: { + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)' + } } - } - }} - /> -
    - )} - - - {!view || view === 'card' ? ( - <> - {isLoading ? ( - - - - - - ) : ( - - {getAllTemplatesMarketplacesApi.data - ?.filter(filterByBadge) - .filter(filterByType) - .filter(filterFlows) - .filter(filterByFramework) - .filter(filterByUsecases) - .map((data, index) => ( - - {data.badge && ( - - {(data.type === 'Chatflow' || + }} + /> +
    + )} + + + {!view || view === 'card' ? ( + <> + {isLoading ? ( + + + + + + ) : ( + + {getAllTemplatesMarketplacesApi.data + ?.filter(filterByBadge) + .filter(filterByType) + .filter(filterFlows) + .filter(filterByFramework) + .filter(filterByUsecases) + .map((data, index) => ( + + {data.badge && ( + + {(data.type === 'Chatflow' || + data.type === 'Agentflow' || + data.type === 'AgentflowV2' || + data.type === 'Agent') && ( + goToCanvas(data)} + data={data} + images={images[data.id]} + icons={icons[data.id]} + /> + )} + {data.type === 'Tool' && ( + goToTool(data)} /> + )} + + )} + {!data.badge && + (data.type === 'Chatflow' || data.type === 'Agentflow' || - data.type === 'AgentflowV2') && ( + data.type === 'AgentflowV2' || + data.type === 'Agent') && ( goToCanvas(data)} data={data} @@ -745,132 +768,136 @@ const Marketplace = () => { icons={icons[data.id]} /> )} - {data.type === 'Tool' && ( - goToTool(data)} /> - )} - - )} - {!data.badge && - (data.type === 'Chatflow' || - data.type === 'Agentflow' || - data.type === 'AgentflowV2') && ( - goToCanvas(data)} - data={data} - images={images[data.id]} - icons={icons[data.id]} - /> + {!data.badge && data.type === 'Tool' && ( + goToTool(data)} /> )} - {!data.badge && data.type === 'Tool' && ( - goToTool(data)} /> - )} - - ))} - - )} - - ) : ( - - )} + + ))} + + )} + + ) : ( + + )} - {!isLoading && - (!getAllTemplatesMarketplacesApi.data || getAllTemplatesMarketplacesApi.data.length === 0) && ( - - - WorkflowEmptySVG + + WorkflowEmptySVG + +
    No Marketplace Yet
    +
    + )} +
    +
    + + + {templateUsecases.length > 0 && ( + + {templateUsecases.map((usecase, index) => ( + { + setSelectedTemplateUsecases( + event.target.checked + ? [...selectedTemplateUsecases, usecase] + : selectedTemplateUsecases.filter((item) => item !== usecase) + ) + }} + /> + } + label={usecase} /> - -
    No Marketplace Yet
    + ))}
    )} -
    -
    - - - - {templateUsecases.map((usecase, index) => ( - { - setSelectedTemplateUsecases( - event.target.checked - ? [...selectedTemplateUsecases, usecase] - : selectedTemplateUsecases.filter((item) => item !== usecase) - ) - }} - /> - } - label={usecase} - /> - ))} - - {selectedTemplateUsecases.length > 0 && ( - - )} - {!view || view === 'card' ? ( - <> - {isLoading ? ( - - - - - - ) : ( - - {getAllCustomTemplatesApi.data - ?.filter(filterByBadge) - .filter(filterByType) - .filter(filterFlows) - .filter(filterByFramework) - .filter(filterByUsecases) - .map((data, index) => ( - - {data.badge && ( - - {(data.type === 'Chatflow' || + {selectedTemplateUsecases.length > 0 && ( + + )} + {!view || view === 'card' ? ( + <> + {isLoading ? ( + + + + + + ) : ( + + {getAllCustomTemplatesApi.data + ?.filter(filterByBadge) + .filter(filterByType) + .filter(filterFlows) + .filter(filterByFramework) + .filter(filterByUsecases) + .map((data, index) => ( + + {data.badge && ( + + {(data.type === 'Chatflow' || + data.type === 'Agentflow' || + data.type === 'AgentflowV2' || + data.type === 'Agent') && ( + goToCanvas(data)} + data={data} + images={templateImages[data.id]} + icons={templateIcons[data.id]} + /> + )} + {data.type === 'Tool' && ( + goToTool(data)} /> + )} + + )} + {!data.badge && + (data.type === 'Chatflow' || data.type === 'Agentflow' || - data.type === 'AgentflowV2') && ( + data.type === 'AgentflowV2' || + data.type === 'Agent') && ( goToCanvas(data)} data={data} @@ -878,61 +905,46 @@ const Marketplace = () => { icons={templateIcons[data.id]} /> )} - {data.type === 'Tool' && ( - goToTool(data)} /> - )} - - )} - {!data.badge && - (data.type === 'Chatflow' || - data.type === 'Agentflow' || - data.type === 'AgentflowV2') && ( - goToCanvas(data)} - data={data} - images={templateImages[data.id]} - icons={templateIcons[data.id]} - /> + {!data.badge && data.type === 'Tool' && ( + goToTool(data)} /> )} - {!data.badge && data.type === 'Tool' && ( - goToTool(data)} /> - )} - - ))} + + ))} + + )} + + ) : ( + + )} + {!isLoading && (!getAllCustomTemplatesApi.data || getAllCustomTemplatesApi.data.length === 0) && ( + + + WorkflowEmptySVG - )} - - ) : ( - - )} - {!isLoading && (!getAllCustomTemplatesApi.data || getAllCustomTemplatesApi.data.length === 0) && ( - - - WorkflowEmptySVG - -
    No Saved Custom Templates
    -
    - )} -
    -
    -
    +
    No Saved Custom Templates
    + + )} + + + + )} { {error ? ( ) : ( - - - } - id='btn_createUser' - > - Add Role - - - {!isLoading && roles.length === 0 ? ( - - - roles_emptySVG - -
    No Roles Yet
    -
    - ) : ( - <> - - - - - - - Name - Description - Permissions - Assigned Users - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {roles.filter(filterUsers).map((role, index) => ( - - ))} - - )} - -
    -
    + + + + } + id='btn_createUser' + > + Add Role + + + {!isLoading && roles.length === 0 ? ( + + + roles_emptySVG +
    No Roles Yet
    - - )} -
    + ) : ( + <> + + + + + + + Name + Description + Permissions + Assigned Users + + + + + {isLoading ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> + {roles.filter(filterUsers).map((role, index) => ( + + ))} + + )} + +
    +
    +
    +
    + + )} +
    + )} {showCreateEditDialog && ( diff --git a/packages/ui/src/views/serverlogs/index.jsx b/packages/ui/src/views/serverlogs/index.jsx index a0b935a12ce..31a6d5c23ff 100644 --- a/packages/ui/src/views/serverlogs/index.jsx +++ b/packages/ui/src/views/serverlogs/index.jsx @@ -11,7 +11,7 @@ import { markdown } from '@codemirror/lang-markdown' import { sublime } from '@uiw/codemirror-theme-sublime' // material-ui -import { Box, Skeleton, Stack, Select, MenuItem, ListItemButton } from '@mui/material' +import { Box, Fade, ListItemButton, MenuItem, Select, Skeleton, Stack } from '@mui/material' import { useTheme } from '@mui/material/styles' // ui @@ -206,107 +206,109 @@ const Logs = () => { {error ? ( ) : ( - - - {isLoading ? ( - - - - - + + + + {isLoading ? ( + + + + + + + + + + + + + + - - - - - - - - - - ) : ( - <> - - - {selectedTimeSearch === 'Custom' && ( - <> - - From - onStartDateSelected(date)} - selectsStart - startDate={startDate} - endDate={endDate} - maxDate={endDate} - showTimeSelect - timeFormat='HH:mm' - timeIntervals={60} - dateFormat='yyyy MMMM d, h aa' - customInput={} - /> - - - To - onEndDateSelected(date)} - selectsEnd - showTimeSelect - timeFormat='HH:mm' - timeIntervals={60} - startDate={startDate} - endDate={endDate} - minDate={startDate} - maxDate={new Date()} - dateFormat='yyyy MMMM d, h aa' - customInput={} + ) : ( + <> + + + {selectedTimeSearch === 'Custom' && ( + <> + + From + onStartDateSelected(date)} + selectsStart + startDate={startDate} + endDate={endDate} + maxDate={endDate} + showTimeSelect + timeFormat='HH:mm' + timeIntervals={60} + dateFormat='yyyy MMMM d, h aa' + customInput={} + /> + + + To + onEndDateSelected(date)} + selectsEnd + showTimeSelect + timeFormat='HH:mm' + timeIntervals={60} + startDate={startDate} + endDate={endDate} + minDate={startDate} + maxDate={new Date()} + dateFormat='yyyy MMMM d, h aa' + customInput={} + /> + + + )} + + {logData ? ( + + ) : ( + + + LogsEmptySVG - - + +
    No Logs Yet
    +
    )} -
    - {logData ? ( - - ) : ( - - - LogsEmptySVG - -
    No Logs Yet
    -
    - )} - - )} - + + )} + + )} ) diff --git a/packages/ui/src/views/settings/index.jsx b/packages/ui/src/views/settings/index.jsx index 9aafd1a7367..7bdd702569c 100644 --- a/packages/ui/src/views/settings/index.jsx +++ b/packages/ui/src/views/settings/index.jsx @@ -15,7 +15,7 @@ import MainCard from '@/ui-component/cards/MainCard' import Transitions from '@/ui-component/extended/Transitions' import settings from '@/menu-items/settings' import agentsettings from '@/menu-items/agentsettings' -import customAssistantSettings from '@/menu-items/customassistant' +import agentSettings from '@/menu-items/agentsettings' import { useAuth } from '@/hooks/useAuth' // ==============================|| SETTINGS ||============================== // @@ -46,13 +46,20 @@ const Settings = ({ chatflow, isSettingsOpen, isCustomAssistant, anchorEl, isAge useEffect(() => { if (chatflow && !chatflow.id) { - const menus = isAgentCanvas ? agentsettings : settings - const settingsMenu = menus.children.filter((menu) => menu.id === 'loadChatflow') - setSettingsMenu(settingsMenu) + if (isCustomAssistant) { + // New agent at /agents/new — only show Load Agent + const menus = agentSettings + setSettingsMenu(menus.children.filter((menu) => menu.id === 'loadAgent')) + } else { + const menus = isAgentCanvas ? agentsettings : settings + const settingsMenu = menus.children.filter((menu) => menu.id === 'loadChatflow' || menu.id === 'loadAgent') + setSettingsMenu(settingsMenu) + } } else if (chatflow && chatflow.id) { if (isCustomAssistant) { - const menus = customAssistantSettings - setSettingsMenu(menus.children) + const menus = agentSettings + // Hide Load Agent for existing agents — only available on /agents/new + setSettingsMenu(menus.children.filter((menu) => menu.id !== 'loadAgent')) } else { const menus = isAgentCanvas ? agentsettings : settings setSettingsMenu(menus.children) @@ -92,7 +99,7 @@ const Settings = ({ chatflow, isSettingsOpen, isCustomAssistant, anchorEl, isAge pl: `24px` }} onClick={() => { - if (menu.id === 'loadChatflow' && inputFile) { + if ((menu.id === 'loadChatflow' || menu.id === 'loadAgent') && inputFile) { inputFile?.current.click() } else { onSettingsItemClick(menu.id) diff --git a/packages/ui/src/views/tools/index.jsx b/packages/ui/src/views/tools/index.jsx index 10dff435140..099aa2f3349 100644 --- a/packages/ui/src/views/tools/index.jsx +++ b/packages/ui/src/views/tools/index.jsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef } from 'react' // material-ui -import { Box, Stack, ButtonGroup, Skeleton, ToggleButtonGroup, ToggleButton } from '@mui/material' +import { Box, ButtonGroup, Fade, Skeleton, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material' import { useTheme } from '@mui/material/styles' // project imports @@ -157,117 +157,119 @@ const Tools = () => { {error ? ( ) : ( - - - + + - - - - - - - - - inputRef.current.click()} - startIcon={} - sx={{ borderRadius: 2, height: 40 }} - > - Load - - handleFileUpload(e)} - /> - - - } - sx={{ borderRadius: 2, height: 40 }} - > - Create - - - - {isLoading && ( - - - - - - )} - {!isLoading && total > 0 && ( - <> - {!view || view === 'card' ? ( - - {getAllToolsApi.data?.data?.filter(filterTools).map((data, index) => ( - edit(data)} /> - ))} - - ) : ( - - )} - {/* Pagination and Page Size Controls */} - - - )} - {!isLoading && total === 0 && ( - - - ToolEmptySVG + + + + + + + + inputRef.current.click()} + startIcon={} + sx={{ borderRadius: 2, height: 40 }} + > + Load + + handleFileUpload(e)} /> -
    No Tools Created Yet
    -
    - )} -
    + + } + sx={{ borderRadius: 2, height: 40 }} + > + Create + + + + {isLoading && ( + + + + + + )} + {!isLoading && total > 0 && ( + <> + {!view || view === 'card' ? ( + + {getAllToolsApi.data?.data?.filter(filterTools).map((data, index) => ( + edit(data)} /> + ))} + + ) : ( + + )} + {/* Pagination and Page Size Controls */} + + + )} + {!isLoading && total === 0 && ( + + + ToolEmptySVG + +
    No Tools Created Yet
    +
    + )} + + )} { {error ? ( ) : ( - - - } - id='btn_createUser' + + + - Invite User - - - {!isLoading && users.length === 0 ? ( - - - users_emptySVG - -
    No Users Yet
    -
    - ) : ( - <> - - - - - - -   - Email/Name - Assigned Roles - Status - Last Login - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {users.filter(filterUsers).map((item, index) => ( - - ))} - - )} - -
    -
    + } + id='btn_createUser' + > + Invite User + + + {!isLoading && users.length === 0 ? ( + + + users_emptySVG +
    No Users Yet
    - - )} -
    + ) : ( + <> + + + + + + +   + Email/Name + Assigned Roles + Status + Last Login + + + + + {isLoading ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> + {users.filter(filterUsers).map((item, index) => ( + + ))} + + )} + +
    +
    +
    +
    + + )} +
    + )} {showInviteDialog && ( diff --git a/packages/ui/src/views/variables/index.jsx b/packages/ui/src/views/variables/index.jsx index 01e1d3225e4..4057e3466eb 100644 --- a/packages/ui/src/views/variables/index.jsx +++ b/packages/ui/src/views/variables/index.jsx @@ -9,6 +9,7 @@ import { tableCellClasses } from '@mui/material/TableCell' import { Button, Box, + Fade, Skeleton, Stack, Table, @@ -218,205 +219,215 @@ const Variables = () => { {error ? ( ) : ( - - - - } - id='btn_createVariable' + + + - Add Variable - - - {!isLoading && variables.length === 0 ? ( - - - VariablesEmptySVG - -
    No Variables Yet
    -
    - ) : ( - <> - setShowHowToDialog(true)} + > + How To Use + + } + id='btn_createVariable' > - - - - Name - Value - Type - Last Updated - Created - - - - - - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - + Add Variable + + + {!isLoading && variables.length === 0 ? ( + + + VariablesEmptySVG + +
    No Variables Yet
    +
    + ) : ( + <> + +
    + + + Name + Value + Type + Last Updated + Created + + + + + + + + + + {isLoading ? ( + <> + - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {variables.filter(filterVariables).map((variable, index) => ( - - -
    -
    - -
    - {variable.name} -
    + + - {variable.value} + + + + + + + + + + +
    + - + - {moment(variable.updatedDate).format('MMMM Do, YYYY HH:mm:ss')} + - {moment(variable.createdDate).format('MMMM Do, YYYY HH:mm:ss')} + + + + + + + - edit(variable)}> - - + - deleteVariable(variable)} - > - - + - ))} - - )} -
    -
    -
    - {/* Pagination and Page Size Controls */} - - - )} -
    + + ) : ( + <> + {variables.filter(filterVariables).map((variable, index) => ( + + +
    +
    + +
    + {variable.name} +
    +
    + {variable.value} + + + + + {moment(variable.updatedDate).format('MMMM Do, YYYY HH:mm:ss')} + + + {moment(variable.createdDate).format('MMMM Do, YYYY HH:mm:ss')} + + + + edit(variable)} + > + + + + + + + deleteVariable(variable)} + > + + + + +
    + ))} + + )} + + + + {/* Pagination and Page Size Controls */} + + + )} + + )} { {error ? ( ) : ( - - window.history.back()} - search={workspaceUsers.length > 0} - onSearchChange={onSearchChange} - searchPlaceholder={'Search Users'} - title={(workspace?.name || '') + ': Workspace Users'} - description={'Manage workspace users and permissions.'} - > - {workspaceUsers.length > 0 && ( - <> - } - > - Remove Users - + + + window.history.back()} + search={workspaceUsers.length > 0} + onSearchChange={onSearchChange} + searchPlaceholder={'Search Users'} + title={(workspace?.name || '') + ': Workspace Users'} + description={'Manage workspace users and permissions.'} + > + {workspaceUsers.length > 0 && ( + <> + } + > + Remove Users + + } + > + Add User + + + )} + + {!isLoading && workspaceUsers?.length <= 0 ? ( + + + empty_datasetSVG + +
    No Assigned Users Yet
    } + onClick={addUser} > Add User - - )} -
    - {!isLoading && workspaceUsers?.length <= 0 ? ( - - - empty_datasetSVG - -
    No Assigned Users Yet
    - } - onClick={addUser} - > - Add User - -
    - ) : ( - <> - - - - - - - - Email/Name - Role - Status - Last Login - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {(workspaceUsers || []).filter(filterUsers).map((item, index) => ( - - - {item.isOrgOwner ? null : ( - handleUserSelect(event, item)} - inputProps={{ - 'aria-labelledby': item.userId - }} - /> - )} + + ) : ( + <> + +
    + + + + + + Email/Name + Role + Status + Last Login + + + + + {isLoading ? ( + <> + + + - {item.user.name && ( - <> - {item.user.name} -
    - - )} - {item.user.email} +
    - {item.isOrgOwner ? ( - - ) : ( - item.role.name - )} + - {item.isOrgOwner ? ( - <> - ) : ( - <> - {'ACTIVE' === item.status.toUpperCase() && ( - - )} - {'INVITED' === item.status.toUpperCase() && ( - - )} - {'INACTIVE' === item.status.toUpperCase() && ( - - )} - - )} + - {!item.lastLogin - ? 'Never' - : moment(item.lastLogin).format('DD/MM/YYYY HH:mm')} + - {!item.isOrgOwner && item.status.toUpperCase() === 'INVITED' && ( - onEditClick(item)} - > - - - )} - {!item.isOrgOwner && item.status.toUpperCase() === 'ACTIVE' && ( - onEditClick(item)} - > - - - )} +
    - ))} - - )} -
    -
    -
    - - )} -
    + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> + {(workspaceUsers || []).filter(filterUsers).map((item, index) => ( + + + {item.isOrgOwner ? null : ( + handleUserSelect(event, item)} + inputProps={{ + 'aria-labelledby': item.userId + }} + /> + )} + + + {item.user.name && ( + <> + {item.user.name} +
    + + )} + {item.user.email} +
    + + {item.isOrgOwner ? ( + + ) : ( + item.role.name + )} + + + {item.isOrgOwner ? ( + <> + ) : ( + <> + {'ACTIVE' === item.status.toUpperCase() && ( + + )} + {'INVITED' === item.status.toUpperCase() && ( + + )} + {'INACTIVE' === item.status.toUpperCase() && ( + + )} + + )} + + + {!item.lastLogin + ? 'Never' + : moment(item.lastLogin).format('DD/MM/YYYY HH:mm')} + + + {!item.isOrgOwner && item.status.toUpperCase() === 'INVITED' && ( + onEditClick(item)} + > + + + )} + {!item.isOrgOwner && item.status.toUpperCase() === 'ACTIVE' && ( + onEditClick(item)} + > + + + )} + +
    + ))} + + )} + + + + + )} + + )} {showAddUserDialog && ( diff --git a/packages/ui/src/views/workspace/index.jsx b/packages/ui/src/views/workspace/index.jsx index 3c12764dda3..626a5ef2d78 100644 --- a/packages/ui/src/views/workspace/index.jsx +++ b/packages/ui/src/views/workspace/index.jsx @@ -10,6 +10,7 @@ import { Button, Chip, Drawer, + Fade, IconButton, Paper, Skeleton, @@ -401,115 +402,117 @@ const Workspaces = () => { {error ? ( ) : ( - - - } - > - Add New - - - {!isLoading && workspaces.length <= 0 ? ( - - - workspaces_emptySVG - -
    No Workspaces Yet
    -
    - ) : ( - + + - - - - Name - Description - Users - Last Updated - - - - - {isLoading ? ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - <> - {workspaces.filter(filterWorkspaces).map((ds, index) => ( - - ))} - - )} - -
    -
    - )} -
    + } + > + Add New + + + {!isLoading && workspaces.length <= 0 ? ( + + + workspaces_emptySVG + +
    No Workspaces Yet
    +
    + ) : ( + + + + + Name + Description + Users + Last Updated + + + + + {isLoading ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + <> + {workspaces.filter(filterWorkspaces).map((ds, index) => ( + + ))} + + )} + +
    +
    + )} + + )} {showWorkspaceDialog && ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 029afff952d..4a8099355ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,7 +379,7 @@ importers: version: 1.0.2(@langchain/core@1.1.20(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.54.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6))) '@langchain/community': specifier: 1.1.12 - version: 1.1.12(62a419978bb3a1266b92f269755d6c91) + version: 1.1.12(72e5206c4d1ed59dcf2c9f2ae0808b12) '@langchain/core': specifier: 1.1.20 version: 1.1.20(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.54.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6)) @@ -436,7 +436,7 @@ importers: version: 1.3.1(@langchain/core@1.1.20(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.54.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6)))(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@mem0/community': specifier: ^0.0.1 - version: 0.0.1(94c000d6d0bfb45ca9fd065107250ef7) + version: 0.0.1(e748aecd5adaef8863109d5a998ca8de) '@mendable/firecrawl-js': specifier: ^1.18.2 version: 1.25.1 @@ -596,6 +596,9 @@ importers: graphql: specifier: ^16.6.0 version: 16.8.1 + groq-sdk: + specifier: 1.1.2 + version: 1.1.2 html-to-text: specifier: ^9.0.5 version: 9.0.5 @@ -819,6 +822,9 @@ importers: '@keyv/redis': specifier: ^4.2.0 version: 4.3.3 + '@modelcontextprotocol/sdk': + specifier: ^1.10.1 + version: 1.12.0 '@oclif/core': specifier: 4.0.7 version: 4.0.7 @@ -1071,6 +1077,9 @@ importers: winston-daily-rotate-file: specifier: ^5.0.0 version: 5.0.0(winston@3.12.0) + zod: + specifier: ^3.25.76 || ^4 + version: 4.3.6 devDependencies: '@types/content-disposition': specifier: 0.5.8 @@ -1258,6 +1267,9 @@ importers: axios: specifier: 1.12.0 version: 1.12.0(debug@4.3.4) + boring-avatars: + specifier: ^2.0.4 + version: 2.0.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) clsx: specifier: ^1.1.1 version: 1.2.1 @@ -10035,9 +10047,6 @@ packages: axios@1.12.0: resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} - axobject-query@4.0.0: - resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==} - axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -10268,6 +10277,12 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + boring-avatars@2.0.4: + resolution: {integrity: sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -12275,10 +12290,6 @@ packages: resolution: {integrity: sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==} engines: {node: '>=14.18'} - eventsource-parser@3.0.0: - resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -13105,6 +13116,10 @@ packages: groq-sdk@0.19.0: resolution: {integrity: sha512-vdh5h7ORvwvOvutA80dKF81b0gPWHxu6K/GOJBOM0n6p6CSqAVLhFfeS79Ef0j/yCycDR09jqY7jkYz9dLiS6w==} + groq-sdk@1.1.2: + resolution: {integrity: sha512-CZO0XUQQDhn43ri1+lZHxZKpb+bGutgTvFmCJtooexiitGmPqhm1hntOT3nCoaq07e+OpeokVnfUs0i/oQuUaQ==} + hasBin: true + grpc-tools@1.12.4: resolution: {integrity: sha512-5+mLAJJma3BjnW/KQp6JBjUMgvu7Mu3dBvBPd1dcbNIb+qiR0817zDpgPjS7gRb+l/8EVNIa3cB02xI9JLToKg==} hasBin: true @@ -15941,9 +15956,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - object-inspect@1.13.1: - resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -18309,10 +18321,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - side-channel@1.1.0: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} @@ -26727,7 +26735,7 @@ snapshots: transitivePeerDependencies: - aws-crt - '@langchain/community@0.3.49(35d76238ed6f9b14cdc9533c238d027e)': + '@langchain/community@0.3.49(51df1274c3bcd07d26e1894340eaf1f1)': dependencies: '@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(bufferutil@4.0.8)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6))(utf-8-validate@6.0.4)(zod@4.3.6) '@ibm-cloud/watsonx-ai': 1.7.7 @@ -26794,7 +26802,7 @@ snapshots: lodash: 4.17.21 lunary: 0.7.12(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6))(react@18.2.0) mammoth: 1.7.0 - mem0ai: 2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@0.19.0(encoding@0.1.13))(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + mem0ai: 2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@1.1.2)(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)) mongodb: 6.3.0(@aws-sdk/credential-providers@3.1002.0)(socks@2.8.1) mysql2: 3.11.4 neo4j-driver: 5.27.0 @@ -26829,7 +26837,7 @@ snapshots: - handlebars - peggy - '@langchain/community@1.1.12(62a419978bb3a1266b92f269755d6c91)': + '@langchain/community@1.1.12(72e5206c4d1ed59dcf2c9f2ae0808b12)': dependencies: '@browserbasehq/stagehand': 1.9.0(@playwright/test@1.49.1)(bufferutil@4.0.8)(deepmerge@4.3.1)(dotenv@16.4.5)(encoding@0.1.13)(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6))(utf-8-validate@6.0.4)(zod@4.3.6) '@ibm-cloud/watsonx-ai': 1.7.7 @@ -26891,7 +26899,7 @@ snapshots: lodash: 4.17.21 lunary: 0.7.12(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6))(react@18.2.0) mammoth: 1.7.0 - mem0ai: 2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@0.19.0(encoding@0.1.13))(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + mem0ai: 2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@1.1.2)(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)) mongodb: 6.3.0(@aws-sdk/credential-providers@3.1002.0)(socks@2.8.1) mysql2: 3.11.4 neo4j-driver: 5.27.0 @@ -27219,12 +27227,12 @@ snapshots: - encoding - supports-color - '@mem0/community@0.0.1(94c000d6d0bfb45ca9fd065107250ef7)': + '@mem0/community@0.0.1(e748aecd5adaef8863109d5a998ca8de)': dependencies: - '@langchain/community': 0.3.49(35d76238ed6f9b14cdc9533c238d027e) + '@langchain/community': 0.3.49(51df1274c3bcd07d26e1894340eaf1f1) '@langchain/core': 1.1.20(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.54.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(openai@6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@4.3.6)) axios: 1.12.0(debug@4.3.4) - mem0ai: 2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@0.19.0(encoding@0.1.13))(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + mem0ai: 2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@1.1.2)(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)) uuid: 10.0.0 zod: 3.22.4 transitivePeerDependencies: @@ -32672,10 +32680,6 @@ snapshots: transitivePeerDependencies: - debug - axobject-query@4.0.0: - dependencies: - dequal: 2.0.3 - axobject-query@4.1.0: {} azure-storage@2.10.7: @@ -32991,6 +32995,11 @@ snapshots: boolean@3.2.0: {} + boring-avatars@2.0.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + bottleneck@2.19.5: {} bowser@2.11.0: {} @@ -34391,7 +34400,7 @@ snapshots: object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.2 - side-channel: 1.0.6 + side-channel: 1.1.0 which-boxed-primitive: 1.0.2 which-collection: 1.0.2 which-typed-array: 1.1.20 @@ -34805,7 +34814,7 @@ snapshots: is-string: 1.0.7 is-typed-array: 1.1.13 is-weakref: 1.0.2 - object-inspect: 1.13.1 + object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.2 @@ -35431,13 +35440,11 @@ snapshots: eventsource-parser@1.1.2: {} - eventsource-parser@3.0.0: {} - eventsource-parser@3.0.6: {} eventsource@3.0.5: dependencies: - eventsource-parser: 3.0.0 + eventsource-parser: 3.0.6 exa-js@1.0.12(encoding@0.1.13): dependencies: @@ -36706,6 +36713,8 @@ snapshots: transitivePeerDependencies: - encoding + groq-sdk@1.1.2: {} + grpc-tools@1.12.4(encoding@0.1.13): dependencies: '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) @@ -37296,7 +37305,7 @@ snapshots: dependencies: es-errors: 1.3.0 hasown: 2.0.2 - side-channel: 1.0.6 + side-channel: 1.1.0 internal-slot@1.1.0: dependencies: @@ -39623,7 +39632,7 @@ snapshots: transitivePeerDependencies: - encoding - mem0ai@2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@0.19.0(encoding@0.1.13))(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)): + mem0ai@2.1.16(@anthropic-ai/sdk@0.73.0(zod@4.3.6))(@google/genai@0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4))(@mistralai/mistralai@1.14.0(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@qdrant/js-client-rest@1.17.0(typescript@5.5.2))(@supabase/supabase-js@2.39.8(bufferutil@4.0.8)(utf-8-validate@6.0.4))(@types/jest@29.5.14)(@types/pg@8.11.2)(@types/sqlite3@3.1.11)(groq-sdk@1.1.2)(neo4j-driver@5.27.0)(ollama@0.5.11)(pg@8.11.3)(redis@4.6.13)(sqlite3@5.1.7)(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4)): dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@google/genai': 0.7.0(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@6.0.4) @@ -39634,7 +39643,7 @@ snapshots: '@types/pg': 8.11.2 '@types/sqlite3': 3.1.11 axios: 1.12.0(debug@4.3.4) - groq-sdk: 0.19.0(encoding@0.1.13) + groq-sdk: 1.1.2 neo4j-driver: 5.27.0 ollama: 0.5.11 openai: 6.19.0(ws@8.18.3(bufferutil@4.0.8)(utf-8-validate@6.0.4))(zod@3.25.76) @@ -40505,8 +40514,6 @@ snapshots: object-hash@3.0.0: {} - object-inspect@1.13.1: {} - object-inspect@1.13.4: {} object-is@1.1.6: @@ -42059,23 +42066,23 @@ snapshots: qs@6.10.4: dependencies: - side-channel: 1.0.6 + side-channel: 1.1.0 qs@6.11.0: dependencies: - side-channel: 1.0.6 + side-channel: 1.1.0 qs@6.11.2: dependencies: - side-channel: 1.0.6 + side-channel: 1.1.0 qs@6.12.1: dependencies: - side-channel: 1.0.6 + side-channel: 1.1.0 qs@6.13.0: dependencies: - side-channel: 1.0.6 + side-channel: 1.1.0 qs@6.5.5: {} @@ -43408,13 +43415,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.0.6: - dependencies: - call-bind: 1.0.8 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.1 - side-channel@1.1.0: dependencies: es-errors: 1.3.0 @@ -43862,7 +43862,7 @@ snapshots: internal-slot: 1.0.7 regexp.prototype.flags: 1.5.2 set-function-name: 2.0.2 - side-channel: 1.0.6 + side-channel: 1.1.0 string.prototype.trim@1.2.10: dependencies: @@ -44097,8 +44097,8 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 '@types/estree': 1.0.8 acorn: 8.16.0 - aria-query: 5.3.0 - axobject-query: 4.0.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 code-red: 1.0.4 css-tree: 2.3.1 estree-walker: 3.0.3