diff --git a/rnd/autogpt_builder/package.json b/rnd/autogpt_builder/package.json index 193237724..559546101 100644 --- a/rnd/autogpt_builder/package.json +++ b/rnd/autogpt_builder/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", + "ajv": "^8.17.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", diff --git a/rnd/autogpt_builder/src/components/CustomNode.tsx b/rnd/autogpt_builder/src/components/CustomNode.tsx index 5fbeea15d..8280d41e5 100644 --- a/rnd/autogpt_builder/src/components/CustomNode.tsx +++ b/rnd/autogpt_builder/src/components/CustomNode.tsx @@ -1,16 +1,16 @@ -import React, { useState, useEffect, FC, memo, useRef } from 'react'; +import React, { useState, useEffect, FC, memo } from 'react'; import { NodeProps } from 'reactflow'; import 'reactflow/dist/style.css'; import './customnode.css'; import InputModalComponent from './InputModalComponent'; import OutputModalComponent from './OutputModalComponent'; import { BlockSchema } from '@/lib/types'; -import { beautifyString } from '@/lib/utils'; +import { beautifyString, setNestedProperty } from '@/lib/utils'; import { Switch } from "@/components/ui/switch" import NodeHandle from './NodeHandle'; import NodeInputField from './NodeInputField'; -type CustomNodeData = { +export type CustomNodeData = { blockType: string; title: string; inputSchema: BlockSchema; @@ -21,6 +21,10 @@ type CustomNodeData = { isOutputOpen: boolean; status?: string; output_data?: any; + block_id: string; + backend_id?: string; + errors?: { [key: string]: string | null }; + setErrors: (errors: { [key: string]: string | null }) => void; }; const CustomNode: FC> = ({ data, id }) => { @@ -29,9 +33,7 @@ const CustomNode: FC> = ({ data, id }) => { const [isModalOpen, setIsModalOpen] = useState(false); const [activeKey, setActiveKey] = useState(null); const [modalValue, setModalValue] = useState(''); - const [errors, setErrors] = useState<{ [key: string]: string | null }>({}); const [isOutputModalOpen, setIsOutputModalOpen] = useState(false); - const outputDataRef = useRef(null); useEffect(() => { if (data.output_data || data.status) { @@ -80,11 +82,13 @@ const CustomNode: FC> = ({ data, id }) => { console.log(`Updating hardcoded values for node ${id}:`, newValues); data.setHardcodedValues(newValues); - setErrors((prevErrors) => ({ ...prevErrors, [key]: null })); + const errors = data.errors || {}; + // Remove error with the same key + setNestedProperty(errors, key, null); + data.setErrors({ ...errors }); }; const getValue = (key: string) => { - console.log(`Getting value for key: ${key}`); const keys = key.split('.'); return keys.reduce((acc, k) => (acc && acc[k] !== undefined) ? acc[k] : '', data.hardcodedValues); }; @@ -122,28 +126,6 @@ const CustomNode: FC> = ({ data, id }) => { setActiveKey(null); }; - const validateInputs = () => { - const newErrors: { [key: string]: string | null } = {}; - const validateRecursive = (schema: any, parentKey: string = '') => { - Object.entries(schema.properties).forEach(([key, propSchema]: [string, any]) => { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - const value = getValue(fullKey); - - if (propSchema.type === 'object' && propSchema.properties) { - validateRecursive(propSchema, fullKey); - } else { - if (propSchema.required && !value) { - newErrors[fullKey] = `${fullKey} is required`; - } - } - }); - }; - - validateRecursive(data.inputSchema); - setErrors(newErrors); - return Object.values(newErrors).every((error) => error === null); - }; - const handleOutputClick = () => { setIsOutputModalOpen(true); setModalValue(typeof data.output_data === 'object' ? JSON.stringify(data.output_data, null, 2) : data.output_data); @@ -166,16 +148,16 @@ const CustomNode: FC> = ({ data, id }) => { const isRequired = data.inputSchema.required?.includes(key); return (isRequired || isAdvancedOpen) && (
- + {!isHandleConnected(key) && - } + }
); })} @@ -196,9 +178,9 @@ const CustomNode: FC> = ({ data, id }) => { const outputText = typeof data.output_data === 'object' ? JSON.stringify(data.output_data) : data.output_data; - + if (!outputText) return 'No output data'; - + return outputText.length > 100 ? `${outputText.slice(0, 100)}... Press To Read More` : outputText; diff --git a/rnd/autogpt_builder/src/components/Flow.tsx b/rnd/autogpt_builder/src/components/Flow.tsx index 95f792cca..4ef066922 100644 --- a/rnd/autogpt_builder/src/components/Flow.tsx +++ b/rnd/autogpt_builder/src/components/Flow.tsx @@ -13,32 +13,17 @@ import ReactFlow, { MarkerType, } from 'reactflow'; import 'reactflow/dist/style.css'; -import CustomNode from './CustomNode'; +import CustomNode, { CustomNodeData } from './CustomNode'; import './flow.css'; import AutoGPTServerAPI, { Block, Graph, NodeExecutionResult, ObjectSchema } from '@/lib/autogpt-server-api'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { ChevronRight, ChevronLeft } from "lucide-react"; -import { deepEquals, getTypeColor } from '@/lib/utils'; +import { deepEquals, getTypeColor, removeEmptyStringsAndNulls, setNestedProperty } from '@/lib/utils'; import { beautifyString } from '@/lib/utils'; import { CustomEdge, CustomEdgeData } from './CustomEdge'; import ConnectionLine from './ConnectionLine'; - - -type CustomNodeData = { - blockType: string; - title: string; - inputSchema: ObjectSchema; - outputSchema: ObjectSchema; - hardcodedValues: { [key: string]: any }; - setHardcodedValues: (values: { [key: string]: any }) => void; - connections: Array<{ source: string; sourceHandle: string; target: string; targetHandle: string }>; - isOutputOpen: boolean; - status?: string; - output_data?: any; - block_id: string; - backend_id?: string; -}; +import Ajv from 'ajv'; const Sidebar: React.FC<{ isOpen: boolean, availableNodes: Block[], addNode: (id: string, name: string) => void }> = ({ isOpen, availableNodes, addNode }) => { @@ -69,6 +54,8 @@ const Sidebar: React.FC<{ isOpen: boolean, availableNodes: Block[], addNode: (id ); }; +const ajv = new Ajv({ strict: false, allErrors: true }); + const FlowEditor: React.FC<{ flowID?: string; template?: boolean; @@ -225,6 +212,13 @@ const FlowEditor: React.FC<{ connections: [], isOutputOpen: false, block_id: blockId, + setErrors: (errors: { [key: string]: string | null }) => { + setNodes((nds) => nds.map((node) => + node.id === newNode.id + ? { ...node, data: { ...node.data, errors } } + : node + )); + } }, }; @@ -265,6 +259,13 @@ const FlowEditor: React.FC<{ targetHandle: link.sink_name, })), isOutputOpen: false, + setErrors: (errors: { [key: string]: string | null }) => { + setNodes((nds) => nds.map((node) => + node.id === newNode.id + ? { ...node, data: { ...node.data, errors } } + : node + )); + } }, }; return newNode; @@ -323,12 +324,13 @@ const FlowEditor: React.FC<{ return inputData; }; - async function saveAgent (asTemplate: boolean = false) { + async function saveAgent(asTemplate: boolean = false) { setNodes((nds) => nds.map((node) => ({ ...node, data: { ...node.data, + hardcodedValues: removeEmptyStringsAndNulls(node.data.hardcodedValues), status: undefined, }, })) @@ -363,6 +365,10 @@ const FlowEditor: React.FC<{ input_default: inputDefault, input_nodes: inputNodes, output_nodes: outputNodes, + data: { + ...node.data, + hardcodedValues: removeEmptyStringsAndNulls(node.data.hardcodedValues), + }, metadata: { position: node.position } }; }); @@ -391,7 +397,7 @@ const FlowEditor: React.FC<{ const newSavedAgent = savedAgent ? await (savedAgent.is_template - ? api.updateTemplate(savedAgent.id, payload) + ? api.updateTemplate(savedAgent.id, payload) : api.updateGraph(savedAgent.id, payload)) : await (asTemplate ? api.createTemplate(payload) @@ -422,6 +428,40 @@ const FlowEditor: React.FC<{ return newSavedAgent.id; }; + const validateNodes = (): boolean => { + let isValid = true; + + nodes.forEach(node => { + const validate = ajv.compile(node.data.inputSchema); + const errors = {} as { [key: string]: string | null }; + + // Validate values against schema using AJV + const valid = validate(node.data.hardcodedValues); + if (!valid) { + // Populate errors if validation fails + validate.errors?.forEach((error) => { + // Skip error if there's an edge connected + const handle = error.instancePath.split(/[\/.]/)[0]; + if (node.data.connections.some(conn => conn.target === node.id || conn.targetHandle === handle)) { + return; + } + isValid = false; + if (error.instancePath && error.message) { + const key = error.instancePath.slice(1); + console.log("Error", key, error.message); + setNestedProperty(errors, key, error.message[0].toUpperCase() + error.message.slice(1)); + } else if (error.keyword === "required") { + const key = error.params.missingProperty; + setNestedProperty(errors, key, "This field is required"); + } + }); + } + node.data.setErrors(errors); + }); + + return isValid; + }; + const runAgent = async () => { try { const newAgentId = await saveAgent(); @@ -430,6 +470,11 @@ const FlowEditor: React.FC<{ return; } + if (!validateNodes()) { + console.error('Validation failed; aborting run'); + return; + } + api.subscribeToExecution(newAgentId); api.runGraph(newAgentId); @@ -438,8 +483,6 @@ const FlowEditor: React.FC<{ } }; - - const updateNodesWithExecutionData = (executionData: NodeExecutionResult[]) => { setNodes((nds) => nds.map((node) => { diff --git a/rnd/autogpt_builder/src/components/NodeHandle.tsx b/rnd/autogpt_builder/src/components/NodeHandle.tsx index 46516e763..381a76d51 100644 --- a/rnd/autogpt_builder/src/components/NodeHandle.tsx +++ b/rnd/autogpt_builder/src/components/NodeHandle.tsx @@ -8,10 +8,11 @@ type HandleProps = { keyName: string, schema: BlockSchema, isConnected: boolean, + isRequired?: boolean, side: 'left' | 'right' } -const NodeHandle: FC = ({ keyName, isConnected, schema, side }) => { +const NodeHandle: FC = ({ keyName, schema, isConnected, isRequired, side }) => { const typeName: Record = { string: 'text', @@ -26,7 +27,9 @@ const NodeHandle: FC = ({ keyName, isConnected, schema, side }) => const label = (
- {schema.title || beautifyString(keyName)} + + {schema.title || beautifyString(keyName)}{isRequired ? '*' : ''} + {typeName[schema.type]}
); diff --git a/rnd/autogpt_builder/src/components/NodeInputField.tsx b/rnd/autogpt_builder/src/components/NodeInputField.tsx index f2b81fda6..583d03327 100644 --- a/rnd/autogpt_builder/src/components/NodeInputField.tsx +++ b/rnd/autogpt_builder/src/components/NodeInputField.tsx @@ -10,7 +10,7 @@ type BlockInputFieldProps = { value: string | Array | { [key: string]: string } handleInputClick: (key: string) => void handleInputChange: (key: string, value: any) => void - errors: { [key: string]: string | null } + errors?: { [key: string]: string } | string | null } const NodeInputField: FC = @@ -20,7 +20,7 @@ const NodeInputField: FC = const [keyValuePairs, setKeyValuePairs] = useState<{ key: string, value: string }[]>([]); const fullKey = parentKey ? `${parentKey}.${key}` : key; - const error = errors[fullKey]; + const error = typeof errors === 'string' ? errors : errors?.[key] ?? ""; const displayKey = schema.title || beautifyString(key); const handleAddProperty = () => { @@ -35,14 +35,15 @@ const NodeInputField: FC = }; const renderClickableInput = (value: string | null = null, placeholder: string = "", secret: boolean = false) => { + const className = `clickable-input ${error ? 'border-error' : ''}` // if secret is true, then the input field will be a password field if the value is not null return secret ? ( -
handleInputClick(fullKey)}> - {value ? ******** : {placeholder}} +
handleInputClick(fullKey)}> + {value ? ******** : {placeholder}}
) : ( -
handleInputClick(fullKey)}> +
handleInputClick(fullKey)}> {value || {placeholder}}
) @@ -247,11 +248,11 @@ const NodeInputField: FC = case 'integer': return (
- handleInputChange(fullKey, parseFloat(e.target.value))} - className="number-input" + className={`number-input ${error ? 'border-error' : ''}`} /> {error && {error}}
@@ -263,7 +264,7 @@ const NodeInputField: FC =
{arrayValues.map((item: string, index: number) => (
- handleInputChange(`${fullKey}.${index}`, e.target.value)} @@ -277,7 +278,7 @@ const NodeInputField: FC = - {error && {error}} + {error && {error}}
); } diff --git a/rnd/autogpt_builder/src/components/customnode.css b/rnd/autogpt_builder/src/components/customnode.css index 0802dd7e1..daabf0573 100644 --- a/rnd/autogpt_builder/src/components/customnode.css +++ b/rnd/autogpt_builder/src/components/customnode.css @@ -54,6 +54,10 @@ position: relative; } +.border-error { + border: 1px solid #d9534f; +} + .clickable-input span { display: inline-block; white-space: nowrap; @@ -82,7 +86,6 @@ width: 100%; padding: 5px; border-radius: 4px; - border: 1px solid #000; background: #fff; color: #000; } diff --git a/rnd/autogpt_builder/src/lib/utils.ts b/rnd/autogpt_builder/src/lib/utils.ts index f4e24d59e..a8968085d 100644 --- a/rnd/autogpt_builder/src/lib/utils.ts +++ b/rnd/autogpt_builder/src/lib/utils.ts @@ -119,3 +119,40 @@ export function exportAsJSONFile(obj: object, filename: string): void { // Clean up URL.revokeObjectURL(url); } + +export function setNestedProperty(obj: any, path: string, value: any) { + const keys = path.split(/[\/.]/); // Split by / or . + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key] || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key]; + } + + current[keys[keys.length - 1]] = value; +} + +export function removeEmptyStringsAndNulls(obj: any): any { + if (Array.isArray(obj)) { + // If obj is an array, recursively remove empty strings and nulls from its elements + return obj + .map(item => removeEmptyStringsAndNulls(item)) + .filter(item => item !== null && (typeof item !== 'string' || item.trim() !== '')); + } else if (typeof obj === 'object' && obj !== null) { + // If obj is an object, recursively remove empty strings and nulls from its properties + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const value = obj[key]; + if (value === null || (typeof value === 'string' && value.trim() === '')) { + delete obj[key]; + } else { + obj[key] = removeEmptyStringsAndNulls(value); + } + } + } + } + return obj; +} diff --git a/rnd/autogpt_builder/yarn.lock b/rnd/autogpt_builder/yarn.lock index fc24fc21f..768df16b2 100644 --- a/rnd/autogpt_builder/yarn.lock +++ b/rnd/autogpt_builder/yarn.lock @@ -997,6 +997,16 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -2075,6 +2085,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.1.tgz#cddd2eecfc83a71c1be2cc2ef2061331be8a7134" + integrity sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw== + fastq@^1.6.0: version "1.17.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" @@ -2703,6 +2718,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -3712,6 +3732,11 @@ remark-rehype@^11.0.0: unified "^11.0.0" vfile "^6.0.0" +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"