implement in new builder

This commit is contained in:
Reinier van der Leer
2025-12-31 16:43:50 +01:00
parent a0dcae5a39
commit b0e4e7b6da
21 changed files with 1266 additions and 140 deletions

View File

@@ -57,18 +57,17 @@ export const RunInputDialog = ({
Credentials
</Text>
</div>
<div className="px-2">
<FormRenderer
jsonSchema={credentialsSchema as RJSFSchema}
handleChange={(v) => handleCredentialChange(v.formData)}
uiSchema={credentialsUiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
<FormRenderer
className="mt-4 px-2"
jsonSchema={credentialsSchema as RJSFSchema}
handleChange={(v) => handleCredentialChange(v.formData)}
uiSchema={credentialsUiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
)}
@@ -80,18 +79,17 @@ export const RunInputDialog = ({
Inputs
</Text>
</div>
<div className="px-2">
<FormRenderer
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
<FormRenderer
className="mt-4 px-2"
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/def
import {
useGetV1GetExecutionDetails,
useGetV1GetSpecificGraph,
useGetV1ListUserGraphs,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
@@ -17,6 +18,7 @@ import { useReactFlow } from "@xyflow/react";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { useHistoryStore } from "../../../stores/historyStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { okData } from "@/app/api/helpers";
export const useFlow = () => {
const [isLocked, setIsLocked] = useState(false);
@@ -35,6 +37,9 @@ export const useFlow = () => {
const setGraphExecutionStatus = useGraphStore(
useShallow((state) => state.setGraphExecutionStatus),
);
const setAvailableSubGraphs = useGraphStore(
useShallow((state) => state.setAvailableSubGraphs),
);
const updateEdgeBeads = useEdgeStore(
useShallow((state) => state.updateEdgeBeads),
);
@@ -61,6 +66,11 @@ export const useFlow = () => {
},
);
// Fetch all available graphs for sub-agent update detection
const { data: availableGraphs } = useGetV1ListUserGraphs({
query: { select: okData },
});
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {},
@@ -115,6 +125,13 @@ export const useFlow = () => {
}
}, [graph]);
// Update available sub-graphs in store for sub-agent update detection
useEffect(() => {
if (availableGraphs) {
setAvailableSubGraphs(availableGraphs);
}
}, [availableGraphs, setAvailableSubGraphs]);
// adding nodes
useEffect(() => {
if (customNodes.length > 0) {

View File

@@ -8,6 +8,7 @@ import {
getBezierPath,
} from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { XIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
@@ -35,6 +36,9 @@ const CustomEdge = ({
selected,
}: EdgeProps<CustomEdge>) => {
const removeConnection = useEdgeStore((state) => state.removeEdge);
// Subscribe to the actual brokenEdgeIds state so we re-render when it changes
const isBroken = useNodeStore((state) => state.brokenEdgeIDs.has(id));
const removeBrokenEdgeID = useNodeStore((state) => state.removeBrokenEdgeID);
const [isHovered, setIsHovered] = useState(false);
const [edgePath, labelX, labelY] = getBezierPath({
@@ -50,6 +54,14 @@ const CustomEdge = ({
const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0;
const handleRemoveEdge = () => {
removeConnection(id);
// Also remove from broken edges tracking if it was broken
if (isBroken) {
removeBrokenEdgeID(id);
}
};
return (
<>
<BaseEdge
@@ -57,9 +69,11 @@ const CustomEdge = ({
markerEnd={markerEnd}
className={cn(
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
selected
? "stroke-zinc-800"
: "stroke-zinc-500/50 hover:stroke-zinc-500",
isBroken
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
: selected
? "stroke-zinc-800"
: "stroke-zinc-500/50 hover:stroke-zinc-500",
)}
/>
<JSBeads
@@ -70,12 +84,16 @@ const CustomEdge = ({
/>
<EdgeLabelRenderer>
<Button
onClick={() => removeConnection(id)}
onClick={handleRemoveEdge}
className={cn(
"absolute h-fit min-w-0 p-1 transition-opacity",
isHovered ? "opacity-100" : "opacity-0",
isBroken
? "bg-red-500 opacity-100 hover:bg-red-600"
: isHovered
? "opacity-100"
: "opacity-0",
)}
variant="secondary"
variant={isBroken ? "primary" : "secondary"}
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: "all",

View File

@@ -1,27 +1,39 @@
import { CircleIcon } from "@phosphor-icons/react";
import { Handle, Position } from "@xyflow/react";
import { cn } from "@/lib/utils";
type NodeHandleProps = {
handleId: string;
isConnected: boolean;
side: "left" | "right";
isBroken?: boolean;
};
const NodeHandle = ({
handleId,
isConnected,
side,
}: {
handleId: string;
isConnected: boolean;
side: "left" | "right";
}) => {
isBroken = false,
}: NodeHandleProps) => {
return (
<Handle
type={side === "left" ? "target" : "source"}
position={side === "left" ? Position.Left : Position.Right}
id={handleId}
className={side === "left" ? "-ml-4 mr-2" : "-mr-2 ml-2"}
className={cn(
side === "left" ? "-ml-4 mr-2" : "-mr-2 ml-2",
isBroken && "pointer-events-none",
)}
isConnectable={!isBroken}
>
<div className="pointer-events-none">
<CircleIcon
size={16}
weight={isConnected ? "fill" : "duotone"}
className={"text-gray-400 opacity-100"}
className={cn(
"opacity-100",
isBroken ? "text-red-500" : "text-gray-400",
)}
/>
</div>
</Handle>

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { Node as XYNode, NodeProps } from "@xyflow/react";
import { RJSFSchema } from "@rjsf/utils";
import { BlockUIType } from "../../../types";
@@ -19,6 +19,55 @@ import { cn } from "@/lib/utils";
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
import { useNodeStore, NodeResolutionData } from "../../../../stores/nodeStore";
type SchemaProperties = Record<string, unknown>;
/**
* Merges schemas during resolution mode to include removed inputs/outputs
* that still have connections, so users can see and delete them.
*/
function mergeSchemaForResolution(
currentSchema: Record<string, unknown>,
newSchema: Record<string, unknown>,
resolutionData: NodeResolutionData,
type: "input" | "output",
): Record<string, unknown> {
const newProps = (newSchema.properties as SchemaProperties) || {};
const currentProps = (currentSchema.properties as SchemaProperties) || {};
const mergedProps = { ...newProps };
const incomp = resolutionData.incompatibilities;
if (type === "input") {
// Add back missing inputs that have connections
incomp.missingInputs.forEach((inputName: string) => {
if (currentProps[inputName]) {
mergedProps[inputName] = currentProps[inputName];
}
});
// Add back inputs with type mismatches (keep old type so connection works visually)
incomp.inputTypeMismatches.forEach(
(mismatch: { name: string; oldType: string; newType: string }) => {
if (currentProps[mismatch.name]) {
mergedProps[mismatch.name] = currentProps[mismatch.name];
}
},
);
} else {
// Add back missing outputs that have connections
incomp.missingOutputs.forEach((outputName: string) => {
if (currentProps[outputName]) {
mergedProps[outputName] = currentProps[outputName];
}
});
}
return {
...newSchema,
properties: mergedProps,
};
}
export type CustomNodeData = {
hardcodedValues: {
@@ -44,6 +93,53 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
({ data, id: nodeId, selected }) => {
// Subscribe to the actual resolution mode state for this node
const isInResolutionMode = useNodeStore((state) =>
state.nodesInResolutionMode.has(nodeId),
);
const resolutionData = useNodeStore((state) =>
state.nodeResolutionData.get(nodeId),
);
const isAgent = data.uiType === BlockUIType.AGENT;
// Get base schemas for agent nodes
const currentInputSchema = isAgent
? (data.hardcodedValues.input_schema ?? {})
: data.inputSchema;
const currentOutputSchema = isAgent
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
// During resolution mode, merge old connected inputs/outputs with new schema
// so users can see and delete the broken connections
const inputSchema = useMemo(() => {
if (isAgent && isInResolutionMode && resolutionData) {
// Use the stored old schema from resolution data for merging
return mergeSchemaForResolution(
resolutionData.currentSchema.input_schema,
resolutionData.pendingUpdate.input_schema,
resolutionData,
"input",
);
}
return currentInputSchema;
}, [isAgent, isInResolutionMode, resolutionData, currentInputSchema]);
const outputSchema = useMemo(() => {
if (isAgent && isInResolutionMode && resolutionData) {
// Use the stored old schema from resolution data for merging
return mergeSchemaForResolution(
resolutionData.currentSchema.output_schema,
resolutionData.pendingUpdate.output_schema,
resolutionData,
"output",
);
}
return currentOutputSchema;
}, [isAgent, isInResolutionMode, resolutionData, currentOutputSchema]);
// Handle sticky note separately
if (data.uiType === BlockUIType.NOTE) {
return (
<StickyNoteBlock data={data} selected={selected} nodeId={nodeId} />
@@ -62,16 +158,6 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
const isAyrshare = data.uiType === BlockUIType.AYRSHARE;
const inputSchema =
data.uiType === BlockUIType.AGENT
? (data.hardcodedValues.input_schema ?? {})
: data.inputSchema;
const outputSchema =
data.uiType === BlockUIType.AGENT
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
const hasConfigErrors =
data.errors &&
Object.values(data.errors).some(
@@ -92,6 +178,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
<div className="rounded-xlarge bg-white">
<NodeHeader data={data} nodeId={nodeId} />
{isAgent && <SubAgentUpdateFeature nodeID={nodeId} nodeData={data} />}
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
{isAyrshare && <AyrshareConnectButton />}
<FormCreator
@@ -99,7 +186,7 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
nodeId={nodeId}
uiType={data.uiType}
className={cn(
"bg-white pr-6",
"my-4 bg-white pr-6",
isWebhook && "pointer-events-none opacity-50",
)}
showHandles={showHandles}

View File

@@ -0,0 +1,273 @@
import React from "react";
import {
WarningIcon,
XCircleIcon,
PlusCircleIcon,
} from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { beautifyString } from "@/lib/utils";
import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate";
type IncompatibleUpdateDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
currentVersion: number;
latestVersion: number;
agentName: string;
incompatibilities: IncompatibilityInfo;
};
export function IncompatibleUpdateDialog({
isOpen,
onClose,
onConfirm,
currentVersion,
latestVersion,
agentName,
incompatibilities,
}: IncompatibleUpdateDialogProps) {
const hasMissingInputs = incompatibilities.missingInputs.length > 0;
const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
const hasNewInputs = incompatibilities.newInputs.length > 0;
const hasNewOutputs = incompatibilities.newOutputs.length > 0;
const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
const hasInputChanges = hasMissingInputs || hasNewInputs;
const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
return (
<Dialog
title={
<div className="flex items-center gap-2">
<WarningIcon className="h-5 w-5 text-amber-500" weight="fill" />
Incompatible Update
</div>
}
controlled={{
isOpen,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "32rem" }}
>
<Dialog.Content>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Updating <strong>{beautifyString(agentName)}</strong> from v
{currentVersion} to v{latestVersion} will break some connections.
</p>
{/* Input changes - two column layout */}
{hasInputChanges && (
<TwoColumnSection
title="Input Changes"
leftIcon={
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
}
leftTitle="Removed"
leftItems={incompatibilities.missingInputs}
rightIcon={
<PlusCircleIcon
className="h-4 w-4 text-green-500"
weight="fill"
/>
}
rightTitle="Added"
rightItems={incompatibilities.newInputs}
/>
)}
{/* Output changes - two column layout */}
{hasOutputChanges && (
<TwoColumnSection
title="Output Changes"
leftIcon={
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
}
leftTitle="Removed"
leftItems={incompatibilities.missingOutputs}
rightIcon={
<PlusCircleIcon
className="h-4 w-4 text-green-500"
weight="fill"
/>
}
rightTitle="Added"
rightItems={incompatibilities.newOutputs}
/>
)}
{hasTypeMismatches && (
<SingleColumnSection
icon={
<XCircleIcon className="h-4 w-4 text-red-500" weight="fill" />
}
title="Type Changed"
description="These connected inputs have a different type:"
items={incompatibilities.inputTypeMismatches.map(
(m) => `${m.name} (${m.oldType}${m.newType})`,
)}
/>
)}
{hasNewRequired && (
<SingleColumnSection
icon={
<PlusCircleIcon
className="h-4 w-4 text-amber-500"
weight="fill"
/>
}
title="New Required Inputs"
description="These inputs are now required:"
items={incompatibilities.newRequiredInputs}
/>
)}
<div className="rounded-md bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<p>
If you proceed, you&apos;ll need to remove the broken connections
before you can save or run your agent.
</p>
</div>
<Dialog.Footer>
<Button variant="ghost" size="small" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={onConfirm}
className="bg-amber-600 hover:bg-amber-700"
>
Update Anyway
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
);
}
type TwoColumnSectionProps = {
title: string;
leftIcon: React.ReactNode;
leftTitle: string;
leftItems: string[];
rightIcon: React.ReactNode;
rightTitle: string;
rightItems: string[];
};
function TwoColumnSection({
title,
leftIcon,
leftTitle,
leftItems,
rightIcon,
rightTitle,
rightItems,
}: TwoColumnSectionProps) {
return (
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
<span className="font-medium">{title}</span>
<div className="mt-2 grid grid-cols-2 items-start gap-4">
{/* Left column - Breaking changes */}
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
{leftIcon}
<span>{leftTitle}</span>
</div>
<ul className="mt-1.5 space-y-1">
{leftItems.length > 0 ? (
leftItems.map((item) => (
<li
key={item}
className="text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-red-50 px-1 py-0.5 font-mono text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
{item}
</code>
</li>
))
) : (
<li className="text-sm italic text-gray-400 dark:text-gray-500">
None
</li>
)}
</ul>
</div>
{/* Right column - Possible solutions */}
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
{rightIcon}
<span>{rightTitle}</span>
</div>
<ul className="mt-1.5 space-y-1">
{rightItems.length > 0 ? (
rightItems.map((item) => (
<li
key={item}
className="text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-green-50 px-1 py-0.5 font-mono text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
{item}
</code>
</li>
))
) : (
<li className="text-sm italic text-gray-400 dark:text-gray-500">
None
</li>
)}
</ul>
</div>
</div>
</div>
);
}
type SingleColumnSectionProps = {
icon: React.ReactNode;
title: string;
description: string;
items: string[];
};
function SingleColumnSection({
icon,
title,
description,
items,
}: SingleColumnSectionProps) {
return (
<div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center gap-2">
{icon}
<span className="font-medium">{title}</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<ul className="mt-2 space-y-1">
{items.map((item) => (
<li
key={item}
className="ml-4 list-disc text-sm text-gray-700 dark:text-gray-300"
>
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs dark:bg-gray-800">
{item}
</code>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React from "react";
import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate";
type ResolutionModeBarProps = {
incompatibilities: IncompatibilityInfo | null;
};
export function ResolutionModeBar({
incompatibilities,
}: ResolutionModeBarProps): React.ReactElement {
const renderIncompatibilities = () => {
if (!incompatibilities) return <span>No incompatibilities</span>;
const sections: React.ReactNode[] = [];
if (incompatibilities.missingInputs.length > 0) {
sections.push(
<div key="missing-inputs" className="mb-1">
<span className="font-semibold">Missing inputs: </span>
{incompatibilities.missingInputs.map((name, i) => (
<React.Fragment key={name}>
<code className="font-mono">{name}</code>
{i < incompatibilities.missingInputs.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
if (incompatibilities.missingOutputs.length > 0) {
sections.push(
<div key="missing-outputs" className="mb-1">
<span className="font-semibold">Missing outputs: </span>
{incompatibilities.missingOutputs.map((name, i) => (
<React.Fragment key={name}>
<code className="font-mono">{name}</code>
{i < incompatibilities.missingOutputs.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
if (incompatibilities.newRequiredInputs.length > 0) {
sections.push(
<div key="new-required" className="mb-1">
<span className="font-semibold">New required inputs: </span>
{incompatibilities.newRequiredInputs.map((name, i) => (
<React.Fragment key={name}>
<code className="font-mono">{name}</code>
{i < incompatibilities.newRequiredInputs.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
if (incompatibilities.inputTypeMismatches.length > 0) {
sections.push(
<div key="type-mismatches" className="mb-1">
<span className="font-semibold">Type changed: </span>
{incompatibilities.inputTypeMismatches.map((m, i) => (
<React.Fragment key={m.name}>
<code className="font-mono">{m.name}</code>
<span className="text-gray-400">
{" "}
({m.oldType} {m.newType})
</span>
{i < incompatibilities.inputTypeMismatches.length - 1 && ", "}
</React.Fragment>
))}
</div>,
);
}
return <>{sections}</>;
};
return (
<div className="flex items-center justify-between gap-2 rounded-t-xl bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
<div className="flex items-center gap-2">
<WarningIcon className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-700 dark:text-amber-300">
Remove incompatible connections
</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-4 w-4 cursor-help text-amber-500" />
</TooltipTrigger>
<TooltipContent className="max-w-sm">
<p className="mb-2 font-semibold">Incompatible changes:</p>
<div className="text-xs">{renderIncompatibilities()}</div>
<p className="mt-2 text-xs text-gray-400">
{(incompatibilities?.newRequiredInputs.length ?? 0) > 0
? "Replace / delete"
: "Delete"}{" "}
the red connections to continue
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import React from "react";
import { ArrowUpIcon, WarningIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { cn, beautifyString } from "@/lib/utils";
import { CustomNodeData } from "../../CustomNode";
import { useSubAgentUpdateState } from "./useSubAgentUpdateState";
import { IncompatibleUpdateDialog } from "./IncompatibleUpdateDialog";
import { ResolutionModeBar } from "./ResolutionModeBar";
/**
* Inline component for the update bar that can be placed after the header.
* Use this inside the node content where you want the bar to appear.
*/
type SubAgentUpdateFeatureProps = {
nodeID: string;
nodeData: CustomNodeData;
};
export function SubAgentUpdateFeature({
nodeID,
nodeData,
}: SubAgentUpdateFeatureProps) {
const {
updateInfo,
isInResolutionMode,
handleUpdateClick,
showIncompatibilityDialog,
setShowIncompatibilityDialog,
handleConfirmIncompatibleUpdate,
} = useSubAgentUpdateState({ nodeID: nodeID, nodeData: nodeData });
const agentName = nodeData.title || "Agent";
if (!updateInfo.hasUpdate && !isInResolutionMode) {
return null;
}
return (
<>
{isInResolutionMode ? (
<ResolutionModeBar incompatibilities={updateInfo.incompatibilities} />
) : (
<SubAgentUpdateAvailableBar
currentVersion={updateInfo.currentVersion}
latestVersion={updateInfo.latestVersion}
isCompatible={updateInfo.isCompatible}
onUpdate={handleUpdateClick}
/>
)}
{/* Incompatibility dialog - rendered here since this component owns the state */}
{updateInfo.incompatibilities && (
<IncompatibleUpdateDialog
isOpen={showIncompatibilityDialog}
onClose={() => setShowIncompatibilityDialog(false)}
onConfirm={handleConfirmIncompatibleUpdate}
currentVersion={updateInfo.currentVersion}
latestVersion={updateInfo.latestVersion}
agentName={beautifyString(agentName)}
incompatibilities={updateInfo.incompatibilities}
/>
)}
</>
);
}
type SubAgentUpdateAvailableBarProps = {
currentVersion: number;
latestVersion: number;
isCompatible: boolean;
onUpdate: () => void;
};
function SubAgentUpdateAvailableBar({
currentVersion,
latestVersion,
isCompatible,
onUpdate,
}: SubAgentUpdateAvailableBarProps): React.ReactElement {
return (
<div className="flex items-center justify-between gap-2 rounded-t-xl bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
<div className="flex items-center gap-2">
<ArrowUpIcon className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm text-blue-700 dark:text-blue-300">
Update available (v{currentVersion} v{latestVersion})
</span>
{!isCompatible && (
<Tooltip>
<TooltipTrigger asChild>
<WarningIcon className="h-4 w-4 text-amber-500" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="font-medium">Incompatible changes detected</p>
<p className="text-xs text-gray-400">
Click Update to see details
</p>
</TooltipContent>
</Tooltip>
)}
</div>
<Button
size="small"
variant={isCompatible ? "primary" : "outline"}
onClick={onUpdate}
className={cn(
"h-7 text-xs",
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
)}
>
Update
</Button>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import {
useNodeStore,
NodeResolutionData,
} from "@/app/(platform)/build/stores/nodeStore";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import {
useSubAgentUpdate,
createUpdatedHardcodedValues,
getBrokenEdgeIDs,
GraphMetaLike,
} from "@/app/(platform)/build/hooks/useSubAgentUpdate";
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
import { CustomNodeData } from "../../CustomNode";
type UseSubAgentUpdateParams = {
nodeID: string;
nodeData: CustomNodeData;
};
export function useSubAgentUpdateState({
nodeID,
nodeData,
}: UseSubAgentUpdateParams) {
const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
useState(false);
// Get store actions
const updateNodeData = useNodeStore(
useShallow((state) => state.updateNodeData),
);
const setNodeResolutionMode = useNodeStore(
useShallow((state) => state.setNodeResolutionMode),
);
const isNodeInResolutionMode = useNodeStore(
useShallow((state) => state.isNodeInResolutionMode),
);
const setBrokenEdgeIds = useNodeStore(
useShallow((state) => state.setBrokenEdgeIDs),
);
const brokenEdgeIds = useNodeStore(
useShallow((state) => state.brokenEdgeIDs),
);
const getNodeResolutionData = useNodeStore(
useShallow((state) => state.getNodeResolutionData),
);
const edges = useEdgeStore(useShallow((state) => state.edges));
const availableSubGraphs = useGraphStore(
useShallow((state) => state.availableSubGraphs),
);
// Extract agent-specific data
const graphID = nodeData.hardcodedValues?.graph_id as string | undefined;
const graphVersion = nodeData.hardcodedValues?.graph_version as
| number
| undefined;
const currentInputSchema = nodeData.hardcodedValues?.input_schema as
| GraphInputSchema
| undefined;
const currentOutputSchema = nodeData.hardcodedValues?.output_schema as
| GraphOutputSchema
| undefined;
// Get node connections
const nodeConnections = useMemo(() => {
return edges.filter(
(edge) => edge.source === nodeID || edge.target === nodeID,
);
}, [edges, nodeID]);
// Use the sub-agent update hook
const updateInfo = useSubAgentUpdate(
nodeID,
graphID,
graphVersion,
currentInputSchema,
currentOutputSchema,
nodeConnections,
availableSubGraphs,
);
const isInResolutionMode = isNodeInResolutionMode(nodeID);
// Handle update button click
const handleUpdateClick = useCallback(() => {
if (!updateInfo.hasUpdate || !updateInfo.latestFlow) return;
if (updateInfo.isCompatible) {
// Compatible update - apply directly
const newHardcodedValues = createUpdatedHardcodedValues(
nodeData.hardcodedValues,
updateInfo.latestFlow as GraphMetaLike,
);
updateNodeData(nodeID, { hardcodedValues: newHardcodedValues });
} else {
// Incompatible update - show dialog
setShowIncompatibilityDialog(true);
}
}, [
updateInfo.hasUpdate,
updateInfo.latestFlow,
updateInfo.isCompatible,
nodeData.hardcodedValues,
updateNodeData,
nodeID,
]);
// Handle confirming an incompatible update
const handleConfirmIncompatibleUpdate = useCallback(() => {
if (!updateInfo.latestFlow || !updateInfo.incompatibilities) return;
const latestFlow = updateInfo.latestFlow as GraphMetaLike;
// Get the new schemas from the latest flow
const newInputSchema =
(latestFlow.input_schema as Record<string, unknown>) || {};
const newOutputSchema =
(latestFlow.output_schema as Record<string, unknown>) || {};
// Create the updated hardcoded values but DON'T apply them yet
// We'll apply them when resolution is complete
const pendingHardcodedValues = createUpdatedHardcodedValues(
nodeData.hardcodedValues,
latestFlow,
);
// Get broken edge IDs
const brokenIds = getBrokenEdgeIDs(
nodeConnections,
updateInfo.incompatibilities,
nodeID,
);
setBrokenEdgeIds(brokenIds);
// Enter resolution mode with both old and new schemas
// DON'T apply the update yet - keep old schema so connections remain visible
const resolutionData: NodeResolutionData = {
incompatibilities: updateInfo.incompatibilities,
pendingUpdate: {
input_schema: newInputSchema,
output_schema: newOutputSchema,
},
currentSchema: {
input_schema: (currentInputSchema as Record<string, unknown>) || {},
output_schema: (currentOutputSchema as Record<string, unknown>) || {},
},
pendingHardcodedValues,
};
setNodeResolutionMode(nodeID, true, resolutionData);
setShowIncompatibilityDialog(false);
}, [
updateInfo.latestFlow,
updateInfo.incompatibilities,
nodeData.hardcodedValues,
currentInputSchema,
currentOutputSchema,
nodeID,
nodeConnections,
setBrokenEdgeIds,
setNodeResolutionMode,
]);
// Check if resolution is complete (all broken edges removed)
const resolutionData = getNodeResolutionData(nodeID);
// Auto-check resolution on edge changes
useEffect(() => {
if (!isInResolutionMode) return;
// Check if any broken edges still exist
const remainingBroken = Array.from(brokenEdgeIds).filter((edgeId) =>
edges.some((e) => e.id === edgeId),
);
if (remainingBroken.length === 0) {
// Resolution complete - now apply the pending update
if (resolutionData?.pendingHardcodedValues) {
updateNodeData(nodeID, {
hardcodedValues: resolutionData.pendingHardcodedValues,
});
}
setNodeResolutionMode(nodeID, false);
setBrokenEdgeIds([]);
}
}, [
isInResolutionMode,
brokenEdgeIds,
edges,
resolutionData,
updateNodeData,
setNodeResolutionMode,
nodeID,
setBrokenEdgeIds,
]);
return {
updateInfo,
isInResolutionMode,
resolutionData,
showIncompatibilityDialog,
setShowIncompatibilityDialog,
handleUpdateClick,
handleConfirmIncompatibleUpdate,
};
}

View File

@@ -1,30 +1,52 @@
import { RJSFSchema } from "@rjsf/utils";
import React from "react";
import React, { useMemo } from "react";
import { uiSchema } from "./uiSchema";
import { useNodeStore } from "../../../stores/nodeStore";
import { BlockUIType } from "../../types";
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
export const FormCreator = React.memo(
({
jsonSchema,
nodeId,
uiType,
showHandles = true,
className,
}: {
jsonSchema: RJSFSchema;
nodeId: string;
uiType: BlockUIType;
showHandles?: boolean;
className?: string;
}) => {
interface FormCreatorProps {
jsonSchema: RJSFSchema;
nodeId: string;
uiType: BlockUIType;
showHandles?: boolean;
className?: string;
}
export const FormCreator: React.FC<FormCreatorProps> = React.memo(
({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore(
(state) => state.getHardCodedValues,
);
// Subscribe to resolution mode state to get broken inputs
const resolutionData = useNodeStore((state) =>
state.nodeResolutionData.get(nodeId),
);
// Compute the set of broken input handles (only missing inputs, not type mismatches)
// Type mismatches still have a valid handle - only the edge is broken, not the handle itself
const brokenInputs = useMemo(() => {
if (!resolutionData) return new Set<string>();
const broken = new Set<string>();
resolutionData.incompatibilities.missingInputs.forEach((name) =>
broken.add(name),
);
return broken;
}, [resolutionData]);
// Compute a map of inputs with type mismatches -> their new type (for highlighting and display)
const typeMismatchInputs = useMemo(() => {
if (!resolutionData) return new Map<string, string>();
const mismatches = new Map<string, string>();
resolutionData.incompatibilities.inputTypeMismatches.forEach((m) =>
mismatches.set(m.name, m.newType),
);
return mismatches;
}, [resolutionData]);
const handleChange = ({ formData }: any) => {
if ("credentials" in formData && !formData.credentials?.id) {
delete formData.credentials;
@@ -48,20 +70,21 @@ export const FormCreator = React.memo(
: hardcodedValues;
return (
<div className={className}>
<FormRenderer
jsonSchema={jsonSchema}
handleChange={handleChange}
uiSchema={uiSchema}
initialValues={initialValues}
formContext={{
nodeId: nodeId,
uiType: uiType,
showHandles: showHandles,
size: "small",
}}
/>
</div>
<FormRenderer
className={className}
jsonSchema={jsonSchema}
handleChange={handleChange}
uiSchema={uiSchema}
initialValues={initialValues}
formContext={{
nodeId: nodeId,
uiType: uiType,
showHandles: showHandles,
size: "small",
brokenInputs: brokenInputs,
typeMismatchInputs: typeMismatchInputs,
}}
/>
);
},
);

View File

@@ -2,7 +2,7 @@ import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CaretDownIcon, InfoIcon } from "@phosphor-icons/react";
import { RJSFSchema } from "@rjsf/utils";
import { useState } from "react";
import { useMemo, useState } from "react";
import NodeHandle from "../handlers/NodeHandle";
import {
@@ -12,9 +12,32 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { getTypeDisplayInfo } from "./helpers";
import { generateHandleId } from "../handlers/helpers";
import { BlockUIType } from "../../types";
import { cn } from "@/lib/utils";
/**
* Hook to get the set of broken output names for a node in resolution mode.
*/
function useBrokenOutputs(nodeID: string): Set<string> {
// Subscribe to the actual state values, not just methods
const isInResolution = useNodeStore((state) =>
state.nodesInResolutionMode.has(nodeID),
);
const resolutionData = useNodeStore((state) =>
state.nodeResolutionData.get(nodeID),
);
return useMemo(() => {
if (!isInResolution || !resolutionData) {
return new Set<string>();
}
return new Set(resolutionData.incompatibilities.missingOutputs);
}, [isInResolution, resolutionData]);
}
export const OutputHandler = ({
outputSchema,
@@ -28,6 +51,7 @@ export const OutputHandler = ({
const { isOutputConnected } = useEdgeStore();
const properties = outputSchema?.properties || {};
const [isOutputVisible, setIsOutputVisible] = useState(true);
const brokenOutputs = useBrokenOutputs(nodeId);
return (
<div className="flex flex-col items-end justify-between gap-2 rounded-b-xlarge border-t border-slate-200/50 bg-white py-3.5">
@@ -53,6 +77,7 @@ export const OutputHandler = ({
<div className="flex flex-col items-end gap-2">
{Object.entries(properties).map(([key, property]: [string, any]) => {
const isConnected = isOutputConnected(nodeId, key);
const isBroken = brokenOutputs.has(key);
const shouldShow = isConnected || isOutputVisible;
const { displayType, colorClass } = getTypeDisplayInfo(property);
@@ -74,7 +99,13 @@ export const OutputHandler = ({
</Tooltip>
</TooltipProvider>
)}
<Text variant="body" className="text-slate-700">
<Text
variant="body"
className={cn(
"text-slate-700",
isBroken && "text-red-500 line-through",
)}
>
{property?.title || key}{" "}
</Text>
<Text variant="small" as="span" className={colorClass}>
@@ -87,6 +118,7 @@ export const OutputHandler = ({
}
isConnected={isConnected}
side="right"
isBroken={isBroken}
/>
</div>
) : null;

View File

@@ -6,7 +6,7 @@ import {
getTypeTextColor,
getEffectiveType,
} from "@/lib/utils";
import { FC, memo, useCallback, useMemo } from "react";
import { FC, memo, useCallback } from "react";
import { Handle, Position } from "@xyflow/react";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";

View File

@@ -1,12 +1,57 @@
import { useMemo } from "react";
import {
GraphMeta,
GraphMeta as LegacyGraphMeta,
GraphInputSchema,
GraphOutputSchema,
} from "@/lib/autogpt-server-api";
import { GraphMeta as GeneratedGraphMeta } from "@/app/api/__generated__/models/graphMeta";
import { getEffectiveType } from "@/lib/utils";
import { ConnectionData } from "../components/legacy-builder/CustomNode/CustomNode";
// Union type for GraphMeta that works with both legacy and new builder
export type GraphMetaLike = LegacyGraphMeta | GeneratedGraphMeta;
// Generic edge type that works with both builders
// Legacy builder uses ConnectionData with `edge_id`, new builder uses CustomEdge with `id`
export type EdgeLike = {
id?: string;
edge_id?: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
};
// Helper type for schema properties - the generated types are too loose
type SchemaProperties = Record<string, GraphInputSchema["properties"][string]>;
type SchemaRequired = string[];
// Helper to safely extract schema properties
function getSchemaProperties(schema: unknown): SchemaProperties {
if (
schema &&
typeof schema === "object" &&
"properties" in schema &&
typeof schema.properties === "object" &&
schema.properties !== null
) {
return schema.properties as SchemaProperties;
}
return {};
}
function getSchemaRequired(schema: unknown): SchemaRequired {
if (
schema &&
typeof schema === "object" &&
"required" in schema &&
Array.isArray(schema.required)
) {
return schema.required as SchemaRequired;
}
return [];
}
export type IncompatibilityInfo = {
missingInputs: string[]; // Connected inputs that no longer exist
missingOutputs: string[]; // Connected outputs that no longer exist
@@ -20,11 +65,11 @@ export type IncompatibilityInfo = {
}>; // Connected inputs where the type has changed
};
export type SubAgentUpdateInfo = {
export type SubAgentUpdateInfo<T extends GraphMetaLike = GraphMetaLike> = {
hasUpdate: boolean;
currentVersion: number;
latestVersion: number;
latestFlow: GraphMeta | null;
latestFlow: T | null;
isCompatible: boolean;
incompatibilities: IncompatibilityInfo | null;
};
@@ -32,15 +77,15 @@ export type SubAgentUpdateInfo = {
/**
* Checks if a newer version of a sub-agent is available and determines compatibility
*/
export function useSubAgentUpdate(
export function useSubAgentUpdate<T extends GraphMetaLike>(
nodeId: string,
graphID: string | undefined,
graphVersion: number | undefined,
currentInputSchema: GraphInputSchema | undefined,
currentOutputSchema: GraphOutputSchema | undefined,
connections: ConnectionData,
availableFlows: GraphMeta[],
): SubAgentUpdateInfo {
connections: ConnectionData | EdgeLike[],
availableFlows: T[],
): SubAgentUpdateInfo<T> {
// Find the latest version of the same graph
const latestFlow = useMemo(() => {
if (!graphID) return null;
@@ -50,7 +95,7 @@ export function useSubAgentUpdate(
// Check if there's an update available
const hasUpdate = useMemo(() => {
if (!latestFlow || graphVersion === undefined) return false;
return latestFlow.version > graphVersion;
return latestFlow.version! > graphVersion;
}, [latestFlow, graphVersion]);
// Get connected input and output handles for this specific node
@@ -81,15 +126,13 @@ export function useSubAgentUpdate(
return { isCompatible: true, incompatibilities: null };
}
const newInputSchema = latestFlow.input_schema;
const newOutputSchema = latestFlow.output_schema;
const newInputProps = newInputSchema?.properties || {};
const newOutputProps = newOutputSchema?.properties || {};
const newRequiredInputs = newInputSchema?.required || [];
const newInputProps = getSchemaProperties(latestFlow.input_schema);
const newOutputProps = getSchemaProperties(latestFlow.output_schema);
const newRequiredInputs = getSchemaRequired(latestFlow.input_schema);
const currentInputProps = currentInputSchema?.properties || {};
const currentOutputProps = currentOutputSchema?.properties || {};
const currentRequiredInputs = currentInputSchema?.required || [];
const currentInputProps = getSchemaProperties(currentInputSchema);
const currentOutputProps = getSchemaProperties(currentOutputSchema);
const currentRequiredInputs = getSchemaRequired(currentInputSchema);
const incompatibilities: IncompatibilityInfo = {
missingInputs: [],
@@ -187,7 +230,7 @@ export function useSubAgentUpdate(
*/
export function createUpdatedHardcodedValues(
currentHardcodedValues: Record<string, unknown>,
latestFlow: GraphMeta,
latestFlow: GraphMetaLike,
): Record<string, unknown> {
return {
...currentHardcodedValues,
@@ -198,10 +241,11 @@ export function createUpdatedHardcodedValues(
}
/**
* Determines which edges are broken after an incompatible update
* Determines which edges are broken after an incompatible update.
* Works with both legacy ConnectionData (edge_id) and new CustomEdge (id).
*/
export function getBrokenEdgeIDs(
connections: ConnectionData,
connections: ConnectionData | EdgeLike[],
incompatibilities: IncompatibilityInfo,
nodeID: string,
): string[] {
@@ -211,13 +255,17 @@ export function getBrokenEdgeIDs(
);
connections.forEach((conn) => {
// Get edge ID - new builder uses `id`, legacy uses `edge_id`
const edgeID = "id" in conn ? conn.id : conn.edge_id;
if (!edgeID) return;
// Check if this connection uses a missing input (node is target)
if (
conn.target === nodeID &&
conn.targetHandle &&
incompatibilities.missingInputs.includes(conn.targetHandle)
) {
brokenEdgeIds.push(conn.edge_id);
brokenEdgeIds.push(edgeID);
}
// Check if this connection uses an input with a type mismatch (node is target)
@@ -226,7 +274,7 @@ export function getBrokenEdgeIDs(
conn.targetHandle &&
typeMismatchInputNames.has(conn.targetHandle)
) {
brokenEdgeIds.push(conn.edge_id);
brokenEdgeIds.push(edgeID);
}
// Check if this connection uses a missing output (node is source)
@@ -235,7 +283,7 @@ export function getBrokenEdgeIDs(
conn.sourceHandle &&
incompatibilities.missingOutputs.includes(conn.sourceHandle)
) {
brokenEdgeIds.push(conn.edge_id);
brokenEdgeIds.push(edgeID);
}
});

View File

@@ -1,5 +1,6 @@
import { create } from "zustand";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
interface GraphStore {
graphExecutionStatus: AgentExecutionStatus | undefined;
@@ -17,6 +18,10 @@ interface GraphStore {
outputSchema: Record<string, any> | null,
) => void;
// Available graphs; used for sub-graph updates
availableSubGraphs: GraphMeta[];
setAvailableSubGraphs: (graphs: GraphMeta[]) => void;
hasInputs: () => boolean;
hasCredentials: () => boolean;
hasOutputs: () => boolean;
@@ -29,6 +34,7 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
inputSchema: null,
credentialsInputSchema: null,
outputSchema: null,
availableSubGraphs: [],
setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => {
set({
@@ -46,6 +52,8 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) =>
set({ inputSchema, credentialsInputSchema, outputSchema }),
setAvailableSubGraphs: (graphs) => set({ availableSubGraphs: graphs }),
hasOutputs: () => {
const { outputSchema } = get();
return Object.keys(outputSchema?.properties ?? {}).length > 0;

View File

@@ -13,6 +13,25 @@ import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore";
import { BlockUIType } from "../components/types";
import { pruneEmptyValues } from "@/lib/utils";
import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate";
// Resolution mode data stored per node
export type NodeResolutionData = {
incompatibilities: IncompatibilityInfo;
// The NEW schema from the update (what we're updating TO)
pendingUpdate: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
// The OLD schema before the update (what we're updating FROM)
// Needed to merge and show removed inputs during resolution
currentSchema: {
input_schema: Record<string, unknown>;
output_schema: Record<string, unknown>;
};
// The full updated hardcoded values to apply when resolution completes
pendingHardcodedValues: Record<string, unknown>;
};
// Minimum movement (in pixels) required before logging position change to history
// Prevents spamming history with small movements when clicking on inputs inside blocks
@@ -61,7 +80,23 @@ type NodeStore = {
backendId: string,
errors: { [key: string]: string },
) => void;
clearAllNodeErrors: () => void; // Add this
clearAllNodeErrors: () => void;
// Sub-agent resolution mode state
nodesInResolutionMode: Set<string>;
brokenEdgeIDs: Set<string>;
nodeResolutionData: Map<string, NodeResolutionData>; // nodeId -> resolution data
setNodeResolutionMode: (
nodeId: string,
inResolution: boolean,
resolutionData?: NodeResolutionData,
) => void;
isNodeInResolutionMode: (nodeId: string) => boolean;
getNodeResolutionData: (nodeId: string) => NodeResolutionData | undefined;
setBrokenEdgeIDs: (edgeIds: string[]) => void;
removeBrokenEdgeID: (edgeId: string) => void;
isEdgeBroken: (edgeId: string) => boolean;
clearResolutionState: () => void;
};
export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -305,4 +340,67 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
})),
}));
},
// Sub-agent resolution mode state
nodesInResolutionMode: new Set<string>(),
brokenEdgeIDs: new Set<string>(),
nodeResolutionData: new Map<string, NodeResolutionData>(),
setNodeResolutionMode: (
nodeID: string,
inResolution: boolean,
resolutionData?: NodeResolutionData,
) => {
set((state) => {
const newNodesSet = new Set(state.nodesInResolutionMode);
const newResolutionDataMap = new Map(state.nodeResolutionData);
if (inResolution) {
newNodesSet.add(nodeID);
if (resolutionData) {
newResolutionDataMap.set(nodeID, resolutionData);
}
} else {
newNodesSet.delete(nodeID);
newResolutionDataMap.delete(nodeID);
}
return {
nodesInResolutionMode: newNodesSet,
nodeResolutionData: newResolutionDataMap,
};
});
},
isNodeInResolutionMode: (nodeId: string) => {
return get().nodesInResolutionMode.has(nodeId);
},
getNodeResolutionData: (nodeId: string) => {
return get().nodeResolutionData.get(nodeId);
},
setBrokenEdgeIDs: (edgeIds: string[]) => {
set({ brokenEdgeIDs: new Set(edgeIds) });
},
removeBrokenEdgeID: (edgeId: string) => {
set((state) => {
const newSet = new Set(state.brokenEdgeIDs);
newSet.delete(edgeId);
return { brokenEdgeIDs: newSet };
});
},
isEdgeBroken: (edgeId: string) => {
return get().brokenEdgeIDs.has(edgeId);
},
clearResolutionState: () => {
set({
nodesInResolutionMode: new Set<string>(),
brokenEdgeIDs: new Set<string>(),
nodeResolutionData: new Map<string, NodeResolutionData>(),
});
},
}));

View File

@@ -18,7 +18,7 @@ function ErrorPageContent() {
) {
window.location.href = "/login";
} else {
window.location.href = "/marketplace";
window.document.location.reload();
}
}

View File

@@ -13,6 +13,8 @@ type FormContextType = {
uiType?: BlockUIType;
showHandles?: boolean;
size?: "small" | "medium" | "large";
brokenInputs?: Set<string>;
typeMismatchInputs?: Map<string, string>; // Map of input name -> new type
};
type FormRendererProps = {
@@ -21,6 +23,7 @@ type FormRendererProps = {
uiSchema: any;
initialValues: any;
formContext: FormContextType;
className?: string;
};
export const FormRenderer = ({
@@ -29,25 +32,25 @@ export const FormRenderer = ({
uiSchema,
initialValues,
formContext,
className,
}: FormRendererProps) => {
const preprocessedSchema = useMemo(() => {
return preprocessInputSchema(jsonSchema);
}, [jsonSchema]);
return (
<div className={"mt-4"}>
<Form
schema={preprocessedSchema}
validator={customValidator}
fields={fields}
templates={templates}
widgets={widgets}
formContext={formContext}
onChange={handleChange}
uiSchema={uiSchema}
formData={initialValues}
noValidate={true}
liveValidate={false}
/>
</div>
<Form
className={className}
schema={preprocessedSchema}
validator={customValidator}
fields={fields}
templates={templates}
widgets={widgets}
formContext={formContext}
onChange={handleChange}
uiSchema={uiSchema}
formData={initialValues}
noValidate={true}
liveValidate={false}
/>
);
};

View File

@@ -56,11 +56,25 @@ export const AnyOfField = ({
.join("_") || ""
: generateHandleId(idSchema.$id ?? "");
const updatedFormContexrt = { ...formContext, fromAnyOf: true };
const updatedFormContext = { ...formContext, fromAnyOf: true };
const { nodeId, showHandles = true } = updatedFormContexrt;
const {
nodeId,
showHandles = true,
brokenInputs,
typeMismatchInputs,
} = updatedFormContext;
const { isInputConnected } = useEdgeStore();
const isConnected = showHandles ? isInputConnected(nodeId, handleId) : false;
// Check if this input is broken (from formContext, passed by FormCreator)
const isBroken = handleId
? ((brokenInputs as Set<string> | undefined)?.has(handleId) ?? false)
: false;
// Check if this input has a type mismatch, and get the new type if so
const newTypeFromMismatch = handleId
? (typeMismatchInputs as Map<string, string> | undefined)?.get(handleId)
: undefined;
const hasTypeMismatch = !!newTypeFromMismatch;
const {
isNullableType,
nonNull,
@@ -105,7 +119,7 @@ export const AnyOfField = ({
name={name}
registry={registry}
disabled={disabled}
formContext={updatedFormContexrt}
formContext={updatedFormContext}
/>
</div>
);
@@ -125,7 +139,7 @@ export const AnyOfField = ({
name={name}
registry={registry}
disabled={disabled}
formContext={updatedFormContexrt}
formContext={updatedFormContext}
/>
</div>
);
@@ -149,15 +163,24 @@ export const AnyOfField = ({
handleId={handleId}
isConnected={isConnected}
side="left"
isBroken={isBroken}
/>
)}
<Text
variant={formContext.size === "small" ? "body" : "body-medium"}
className={cn(isBroken && "text-red-500 line-through")}
>
{schema.title || name.charAt(0).toUpperCase() + name.slice(1)}
</Text>
<Text variant="small" className={colorClass}>
({displayType} | null)
<Text
variant="small"
className={cn(
colorClass,
isBroken && "text-red-500 line-through",
hasTypeMismatch && "rounded bg-red-100 px-1 !text-red-600",
)}
>
({hasTypeMismatch ? newTypeFromMismatch : displayType} | null)
</Text>
</div>
{!isConnected && (
@@ -168,9 +191,9 @@ export const AnyOfField = ({
/>
)}
</div>
<div className="mt-2">
{!isConnected && isEnabled && renderInput(nonNull)}
</div>
{!isConnected && isEnabled && (
<div className="mt-2">{renderInput(nonNull)}</div>
)}
</div>
);
}
@@ -185,9 +208,13 @@ export const AnyOfField = ({
handleId={handleId}
isConnected={isConnected}
side="left"
isBroken={isBroken}
/>
)}
<Text variant={formContext.size === "small" ? "body" : "body-medium"}>
<Text
variant={formContext.size === "small" ? "body" : "body-medium"}
className={cn(isBroken && "text-red-500 line-through")}
>
{schema.title || name.charAt(0).toUpperCase() + name.slice(1)}
</Text>
{!isConnected && (

View File

@@ -27,7 +27,7 @@ export const ObjectField = (props: FieldProps) => {
}
const fieldKey = generateHandleId(idSchema.$id ?? "");
const { nodeId } = formContext;
const { nodeId, brokenInputs, typeMismatchInputs } = formContext;
return (
<ObjectEditor
@@ -37,6 +37,8 @@ export const ObjectField = (props: FieldProps) => {
value={formData}
onChange={onChange}
placeholder={`Enter ${name || "Contact Data"}`}
brokenInputs={brokenInputs}
typeMismatchInputs={typeMismatchInputs}
/>
);
};

View File

@@ -36,7 +36,13 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
uiSchema,
}) => {
const { isInputConnected } = useEdgeStore();
const { nodeId, showHandles = true, size = "small" } = formContext;
const {
nodeId,
showHandles = true,
size = "small",
brokenInputs,
typeMismatchInputs,
} = formContext;
const uiType = formContext.uiType;
const showAdvanced = useNodeStore(
@@ -56,7 +62,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
const isCredential = isCredentialFieldSchema(schema);
const suppressHandle = isAnyOf || isOneOf;
let handleId = null;
let handleId: string | null = null;
if (!isArrayItem) {
if (uiType === BlockUIType.AGENT) {
const parts = fieldId.split("_");
@@ -72,6 +78,13 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
}
const isConnected = showHandles ? isInputConnected(nodeId, handleId) : false;
// Check if this input is broken (from formContext, passed by FormCreator)
const isBroken = handleId ? (brokenInputs?.has(handleId) ?? false) : false;
// Check if this input has a type mismatch, and get the new type if so
const newTypeFromMismatch = handleId
? (typeMismatchInputs as Map<string, string> | undefined)?.get(handleId)
: undefined;
const hasTypeMismatch = !!newTypeFromMismatch;
if (!showAdvanced && schema.advanced === true && !isConnected) {
return null;
@@ -113,7 +126,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
return (
<div
className={cn(
"mb-4 space-y-2",
"mb-4 space-y-2 last:mb-0", // peer-to-peer spacing since we can't style the parent with gap-y
fromAnyOf && "mb-0",
size === "small" ? "w-[350px]" : "w-full",
)}
@@ -125,6 +138,7 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
handleId={handleId}
isConnected={isConnected}
side="left"
isBroken={isBroken}
/>
)}
<Text
@@ -139,14 +153,21 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
uiType === BlockUIType.INPUT && "ml-3",
uiType === BlockUIType.WEBHOOK && "ml-3",
uiType === BlockUIType.WEBHOOK_MANUAL && "ml-3",
isBroken && "text-red-500 line-through",
)}
>
{isCredential && credentialProvider
? toDisplayName(credentialProvider) + " credentials"
: schema.title || label}
</Text>
<Text variant="small" className={colorClass}>
({displayType})
<Text
variant="small"
className={cn(
colorClass,
hasTypeMismatch && "rounded bg-red-100 px-1 !text-red-600",
)}
>
({hasTypeMismatch ? newTypeFromMismatch : displayType})
</Text>
{required && <span style={{ color: "red" }}>*</span>}
{description?.props?.description && (

View File

@@ -12,6 +12,7 @@ import {
HandleIdType,
parseKeyValueHandleId,
} from "@/app/(platform)/build/components/FlowEditor/handlers/helpers";
import { cn } from "@/lib/utils";
export interface ObjectEditorProps {
id: string;
@@ -22,6 +23,8 @@ export interface ObjectEditorProps {
className?: string;
nodeId: string;
fieldKey: string;
brokenInputs?: Set<string>;
typeMismatchInputs?: Map<string, string>; // Map of input name -> new type
}
export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
@@ -34,6 +37,8 @@ export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
disabled = false,
className,
nodeId,
brokenInputs,
typeMismatchInputs,
},
ref,
) => {
@@ -96,7 +101,7 @@ export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
return (
<div
ref={ref}
className={`flex flex-col gap-2 ${className || ""}`}
className={cn("flex flex-col gap-2", className)}
id={parentFieldId}
>
{Object.entries(value).map(([key, propertyValue], idx) => {
@@ -106,6 +111,13 @@ export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
HandleIdType.KEY_VALUE,
);
const isDynamicPropertyConnected = isInputConnected(nodeId, handleId);
const isBroken = handleId
? (brokenInputs?.has(handleId) ?? false)
: false;
const newTypeFromMismatch = handleId
? typeMismatchInputs?.get(handleId)
: undefined;
const hasTypeMismatch = !!newTypeFromMismatch;
return (
<div key={idx} className="flex flex-col gap-2">
@@ -115,13 +127,27 @@ export const ObjectEditor = React.forwardRef<HTMLDivElement, ObjectEditorProps>(
isConnected={isDynamicPropertyConnected}
handleId={handleId}
side="left"
isBroken={isBroken}
/>
)}
<Text variant="small" className="!text-gray-500">
<Text
variant="small"
className={cn(
"!text-gray-500",
isBroken && "!text-red-500 line-through",
)}
>
#{key.trim() === "" ? "" : key}
</Text>
<Text variant="small" className="!text-green-500">
(string)
<Text
variant="small"
className={cn(
"!text-green-500",
isBroken && "!text-red-500 line-through",
hasTypeMismatch && "rounded bg-red-100 px-1 !text-red-600",
)}
>
({hasTypeMismatch ? newTypeFromMismatch : "string"})
</Text>
</div>
{!isDynamicPropertyConnected && (