mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-07 22:33:57 -05:00
feat(frontend): display graph validation errors inline on node fields (#11524)
When running a graph in the new builder, validation errors were only displayed in toast notifications, making it difficult for users to identify which specific fields had errors. Users needed to see validation errors directly next to the problematic fields within each node for better UX and faster debugging. <img width="1319" height="953" alt="Screenshot 2025-12-03 at 12 48 15 PM" src="https://github.com/user-attachments/assets/d444bc71-9bee-4fa7-8b7f-33339bd0cb24" /> ### Changes 🏗️ - **Error handling in graph execution** (`useRunGraph.ts`): - Added detection for graph validation errors using `ApiError.isGraphValidationError()` - Parse and store node-level errors from backend validation response - Clear all node errors on successful graph execution - Enhanced toast messages to guide users to fix validation errors on highlighted nodes - **Node store error management** (`nodeStore.ts`): - Added `errors` field to node data structure - Implemented `updateNodeErrors()` to set errors for a specific node - Implemented `clearNodeErrors()` to remove errors from a specific node - Implemented `getNodeErrors()` to retrieve errors for a specific node - Implemented `setNodeErrorsForBackendId()` to set errors by backend ID (supports matching by `metadata.backend_id` or node `id`) - Implemented `clearAllNodeErrors()` to clear all node errors across the graph - **Visual error indication** (`CustomNode.tsx`, `NodeContainer.tsx`): - Added error detection logic to identify both configuration errors and output errors - Applied error styling to nodes with validation errors (using `FAILED` status styling) - Nodes with errors now display with red border/ring to visually indicate issues - **Field-level error display** (`FieldTemplate.tsx`): - Fetch node errors from store for the current node - Match field IDs with error keys (handles both underscore and dot notation) - Display field-specific error messages below each field in red text - Added helper function `getFieldErrorKey()` to normalize field IDs for error matching - **Utility helpers** (`helpers.ts`): - Created `getFieldErrorKey()` function to extract field key from field ID (removes `root_` prefix) ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Create a graph with multiple nodes and intentionally leave required fields empty - [x] Run the graph and verify that validation errors appear in toast notification - [x] Verify that nodes with errors are highlighted with red border/ring styling - [x] Verify that field-specific error messages appear below each problematic field in red text - [x] Verify that error messages handle both underscore and dot notation in field keys - [x] Fix validation errors and run graph again - verify errors are cleared - [x] Verify that successful graph execution clears all node errors - [x] Test with nodes that have `backend_id` in metadata vs nodes without - [x] Verify that nodes without errors don't show error styling - [x] Test with nested fields and array fields to ensure error matching works correctly
This commit is contained in:
@@ -9,6 +9,8 @@ import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useState } from "react";
|
||||
import { useSaveGraph } from "@/app/(platform)/build/hooks/useSaveGraph";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { ApiError } from "@/lib/autogpt-server-api/helpers"; // Check if this exists
|
||||
|
||||
export const useRunGraph = () => {
|
||||
const { saveGraph, isSaving } = useSaveGraph({
|
||||
@@ -24,6 +26,13 @@ export const useRunGraph = () => {
|
||||
);
|
||||
const [openRunInputDialog, setOpenRunInputDialog] = useState(false);
|
||||
|
||||
const setNodeErrorsForBackendId = useNodeStore(
|
||||
useShallow((state) => state.setNodeErrorsForBackendId),
|
||||
);
|
||||
const clearAllNodeErrors = useNodeStore(
|
||||
useShallow((state) => state.clearAllNodeErrors),
|
||||
);
|
||||
|
||||
const [{ flowID, flowVersion, flowExecutionID }, setQueryStates] =
|
||||
useQueryStates({
|
||||
flowID: parseAsString,
|
||||
@@ -35,19 +44,49 @@ export const useRunGraph = () => {
|
||||
usePostV1ExecuteGraphAgent({
|
||||
mutation: {
|
||||
onSuccess: (response: any) => {
|
||||
clearAllNodeErrors();
|
||||
const { id } = response.data as GraphExecutionMeta;
|
||||
setQueryStates({
|
||||
flowExecutionID: id,
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Reset running state on error
|
||||
setIsGraphRunning(false);
|
||||
toast({
|
||||
title: (error.detail as string) ?? "An unexpected error occurred.",
|
||||
description: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
if (error instanceof ApiError && error.isGraphValidationError?.()) {
|
||||
const errorData = error.response?.detail;
|
||||
|
||||
if (errorData?.node_errors) {
|
||||
Object.entries(errorData.node_errors).forEach(
|
||||
([backendId, nodeErrors]) => {
|
||||
setNodeErrorsForBackendId(
|
||||
backendId,
|
||||
nodeErrors as { [key: string]: string },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
useNodeStore.getState().nodes.forEach((node) => {
|
||||
const backendId = node.data.metadata?.backend_id || node.id;
|
||||
if (!errorData.node_errors[backendId as string]) {
|
||||
useNodeStore.getState().updateNodeErrors(node.id, {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: errorData?.message || "Graph validation failed",
|
||||
description:
|
||||
"Please fix the validation errors on the highlighted nodes and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title:
|
||||
(error.detail as string) ?? "An unexpected error occurred.",
|
||||
description: "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ export const useFlow = () => {
|
||||
{
|
||||
query: {
|
||||
select: (res) => res.data as BlockInfo[],
|
||||
enabled: !!flowID && !!blockIds,
|
||||
enabled: !!flowID && !!blockIds && blockIds.length > 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ export type CustomNodeData = {
|
||||
costs: BlockCost[];
|
||||
categories: BlockInfoCategoriesItem[];
|
||||
metadata?: NodeModelMetadata;
|
||||
errors?: { [key: string]: string };
|
||||
};
|
||||
|
||||
export type CustomNode = XYNode<CustomNodeData, "custom">;
|
||||
@@ -71,10 +72,24 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
? (data.hardcodedValues.output_schema ?? {})
|
||||
: data.outputSchema;
|
||||
|
||||
const hasConfigErrors =
|
||||
data.errors &&
|
||||
Object.values(data.errors).some(
|
||||
(value) => value !== null && value !== undefined && value !== "",
|
||||
);
|
||||
|
||||
const outputData = data.nodeExecutionResult?.output_data;
|
||||
const hasOutputError =
|
||||
typeof outputData === "object" &&
|
||||
outputData !== null &&
|
||||
"error" in outputData;
|
||||
|
||||
const hasErrors = hasConfigErrors || hasOutputError;
|
||||
|
||||
// Currently all blockTypes design are similar - that's why i am using the same component for all of them
|
||||
// If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
|
||||
return (
|
||||
<NodeContainer selected={selected} nodeId={nodeId}>
|
||||
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
|
||||
<div className="rounded-xlarge bg-white">
|
||||
<NodeHeader data={data} nodeId={nodeId} />
|
||||
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
|
||||
|
||||
@@ -3,15 +3,18 @@ import { nodeStyleBasedOnStatus } from "../helpers";
|
||||
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
|
||||
export const NodeContainer = ({
|
||||
children,
|
||||
nodeId,
|
||||
selected,
|
||||
hasErrors, // these are configuration errors that occur before executing the graph -- more like validation errors
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
nodeId: string;
|
||||
selected: boolean;
|
||||
hasErrors?: boolean;
|
||||
}) => {
|
||||
const status = useNodeStore(
|
||||
useShallow((state) => state.getNodeStatus(nodeId)),
|
||||
@@ -22,6 +25,7 @@ export const NodeContainer = ({
|
||||
"z-12 max-w-[370px] rounded-xlarge ring-1 ring-slate-200/60",
|
||||
selected && "shadow-lg ring-2 ring-slate-200",
|
||||
status && nodeStyleBasedOnStatus[status],
|
||||
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -53,6 +53,15 @@ type NodeStore = {
|
||||
getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
|
||||
getNodeBlockUIType: (nodeId: string) => BlockUIType;
|
||||
hasWebhookNodes: () => boolean;
|
||||
|
||||
updateNodeErrors: (nodeId: string, errors: { [key: string]: string }) => void;
|
||||
clearNodeErrors: (nodeId: string) => void;
|
||||
getNodeErrors: (nodeId: string) => { [key: string]: string } | undefined;
|
||||
setNodeErrorsForBackendId: (
|
||||
backendId: string,
|
||||
errors: { [key: string]: string },
|
||||
) => void;
|
||||
clearAllNodeErrors: () => void; // Add this
|
||||
};
|
||||
|
||||
export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
@@ -253,4 +262,47 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
[BlockUIType.WEBHOOK, BlockUIType.WEBHOOK_MANUAL].includes(n.data.uiType),
|
||||
);
|
||||
},
|
||||
|
||||
updateNodeErrors: (nodeId: string, errors: { [key: string]: string }) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, errors } } : n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearNodeErrors: (nodeId: string) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, errors: undefined } } : n,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
getNodeErrors: (nodeId: string) => {
|
||||
return get().nodes.find((n) => n.id === nodeId)?.data?.errors;
|
||||
},
|
||||
|
||||
setNodeErrorsForBackendId: (
|
||||
backendId: string,
|
||||
errors: { [key: string]: string },
|
||||
) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) => {
|
||||
// Match by backend_id if nodes have it, or by id
|
||||
const matches =
|
||||
n.data.metadata?.backend_id === backendId || n.id === backendId;
|
||||
return matches ? { ...n, data: { ...n.data, errors } } : n;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
clearAllNodeErrors: () => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, errors: undefined },
|
||||
})),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,7 @@ import { cn } from "@/lib/utils";
|
||||
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
|
||||
import { BlockUIType } from "@/lib/autogpt-server-api";
|
||||
import NodeHandle from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
|
||||
import { getFieldErrorKey } from "../utils/helpers";
|
||||
|
||||
const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
id: fieldId,
|
||||
@@ -42,6 +43,11 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
(state) => state.nodeAdvancedStates[nodeId] ?? false,
|
||||
);
|
||||
|
||||
const nodeErrors = useNodeStore((state) => {
|
||||
const node = state.nodes.find((n) => n.id === nodeId);
|
||||
return node?.data?.errors;
|
||||
});
|
||||
|
||||
const { isArrayItem, arrayFieldHandleId } = useContext(ArrayEditorContext);
|
||||
|
||||
const isAnyOf =
|
||||
@@ -89,6 +95,13 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
shouldShowHandle = false;
|
||||
}
|
||||
|
||||
const fieldErrorKey = getFieldErrorKey(fieldId);
|
||||
const fieldError =
|
||||
nodeErrors?.[fieldErrorKey] ||
|
||||
nodeErrors?.[fieldErrorKey.replace(/_/g, ".")] ||
|
||||
nodeErrors?.[fieldErrorKey.replace(/\./g, "_")] ||
|
||||
null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -150,7 +163,12 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
|
||||
<div className={cn(size === "small" ? "max-w-[340px] pl-2" : "")}>
|
||||
{children}
|
||||
</div>
|
||||
)}{" "}
|
||||
)}
|
||||
{fieldError && (
|
||||
<Text variant="small" className="mt-1 pl-4 !text-red-600">
|
||||
{fieldError}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const getFieldErrorKey = (fieldId: string): string => {
|
||||
const withoutRoot = fieldId.startsWith("root_") ? fieldId.slice(5) : fieldId;
|
||||
return withoutRoot;
|
||||
};
|
||||
Reference in New Issue
Block a user