feat(builder): Add input validation on the frontend (#7606)

- Add `ajv` dependency to check values against json schema
- Add `errors` and `setErrors` to `CustomNodeData`
- Add `validateNodes` run before executing agent
- Add `*` on labels for required fields
- Add `setNestedProperty` and `removeEmptyStringsAndNulls` utility function
pull/7607/head^2
Krzysztof Czerwinski 2024-07-29 10:17:27 +01:00 committed by GitHub
parent edf84fb9f8
commit 905b1df218
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 169 additions and 74 deletions

View File

@ -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",

View File

@ -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<NodeProps<CustomNodeData>> = ({ data, id }) => {
@ -29,9 +33,7 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ data, id }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [modalValue, setModalValue] = useState<string>('');
const [errors, setErrors] = useState<{ [key: string]: string | null }>({});
const [isOutputModalOpen, setIsOutputModalOpen] = useState(false);
const outputDataRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (data.output_data || data.status) {
@ -80,11 +82,13 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ 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<NodeProps<CustomNodeData>> = ({ 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<NodeProps<CustomNodeData>> = ({ data, id }) => {
const isRequired = data.inputSchema.required?.includes(key);
return (isRequired || isAdvancedOpen) && (
<div key={key}>
<NodeHandle keyName={key} isConnected={isHandleConnected(key)} schema={schema} side="left" />
<NodeHandle keyName={key} isConnected={isHandleConnected(key)} isRequired={isRequired} schema={schema} side="left" />
{!isHandleConnected(key) &&
<NodeInputField
keyName={key}
schema={schema}
value={getValue(key)}
handleInputClick={handleInputClick}
handleInputChange={handleInputChange}
errors={errors}
/>}
<NodeInputField
keyName={key}
schema={schema}
value={getValue(key)}
handleInputClick={handleInputClick}
handleInputChange={handleInputChange}
errors={data.errors?.[key]}
/>}
</div>
);
})}
@ -196,9 +178,9 @@ const CustomNode: FC<NodeProps<CustomNodeData>> = ({ 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;

View File

@ -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) => {

View File

@ -8,10 +8,11 @@ type HandleProps = {
keyName: string,
schema: BlockSchema,
isConnected: boolean,
isRequired?: boolean,
side: 'left' | 'right'
}
const NodeHandle: FC<HandleProps> = ({ keyName, isConnected, schema, side }) => {
const NodeHandle: FC<HandleProps> = ({ keyName, schema, isConnected, isRequired, side }) => {
const typeName: Record<string, string> = {
string: 'text',
@ -26,7 +27,9 @@ const NodeHandle: FC<HandleProps> = ({ keyName, isConnected, schema, side }) =>
const label = (
<div className="flex flex-col flex-grow">
<span className="text-m text-gray-900 -mb-1 green">{schema.title || beautifyString(keyName)}</span>
<span className="text-m text-gray-900 -mb-1 green">
{schema.title || beautifyString(keyName)}{isRequired ? '*' : ''}
</span>
<span className={typeClass}>{typeName[schema.type]}</span>
</div>
);

View File

@ -10,7 +10,7 @@ type BlockInputFieldProps = {
value: string | Array<string> | { [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<BlockInputFieldProps> =
@ -20,7 +20,7 @@ const NodeInputField: FC<BlockInputFieldProps> =
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<BlockInputFieldProps> =
};
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 ? (
<div className="clickable-input" onClick={() => handleInputClick(fullKey)}>
{value ? <i className="text-gray-500">********</i> : <i className="text-gray-500">{placeholder}</i>}
<div className={className} onClick={() => handleInputClick(fullKey)}>
{value ? <span>********</span> : <i className="text-gray-500">{placeholder}</i>}
</div>
) : (
<div className="clickable-input" onClick={() => handleInputClick(fullKey)}>
<div className={className} onClick={() => handleInputClick(fullKey)}>
{value || <i className="text-gray-500">{placeholder}</i>}
</div>
)
@ -247,11 +248,11 @@ const NodeInputField: FC<BlockInputFieldProps> =
case 'integer':
return (
<div key={fullKey} className="input-container">
<input
<Input
type="number"
value={value as string || ''}
onChange={(e) => handleInputChange(fullKey, parseFloat(e.target.value))}
className="number-input"
className={`number-input ${error ? 'border-error' : ''}`}
/>
{error && <span className="error-message">{error}</span>}
</div>
@ -263,7 +264,7 @@ const NodeInputField: FC<BlockInputFieldProps> =
<div key={fullKey} className="input-container">
{arrayValues.map((item: string, index: number) => (
<div key={`${fullKey}.${index}`} className="array-item-container">
<input
<Input
type="text"
value={item}
onChange={(e) => handleInputChange(`${fullKey}.${index}`, e.target.value)}
@ -277,7 +278,7 @@ const NodeInputField: FC<BlockInputFieldProps> =
<Button onClick={() => handleInputChange(fullKey, [...arrayValues, ''])} className="array-item-add">
Add Item
</Button>
{error && <span className="error-message">{error}</span>}
{error && <span className="error-message ml-2">{error}</span>}
</div>
);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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"