Compare commits

...

2 Commits

Author SHA1 Message Date
Reinier van der Leer
3b09a94e3f feat(frontend/builder): Add sub-graph update UX (#11631)
[OPEN-2743: Ability to Update Sub-Agents in Graph (Without
Re-Adding)](https://linear.app/autogpt/issue/OPEN-2743/ability-to-update-sub-agents-in-graph-without-re-adding)

Updating sub-graphs is a cumbersome experience at the moment, this
should help. :)

Demo in Builder v2:


https://github.com/user-attachments/assets/df564f32-4d1d-432c-bb91-fe9065068360


https://github.com/user-attachments/assets/f169471a-1f22-46e9-a958-ddb72d3f65af


### Changes 🏗️

- Add sub-graph update banner with I/O incompatibility notification and
resolution mode
  - Red visual indicators for broken inputs/outputs and edges
  - Update bars and tooltips show compatibility details
- Sub-agent update UI with compatibility checks, incompatibility dialog,
and guided resolution workflow
- Resolution mode banner guiding users to remove incompatible
connections
- Visual controls to stage/apply updates and auto-apply when broken
connections are fixed
  
  Technical:
- Builder v1: Add `CustomNode` > `IncompatibilityDialog` +
`SubAgentUpdateBar` sub-components
- Builder v2: Add `SubAgentUpdateFeature` + `ResolutionModeBar` +
`IncompatibleUpdateDialog` + `useSubAgentUpdateState` sub-components
  - Add `useSubAgentUpdate` hook

- Related fixes in Builder v1:
  - Fix static edges not rendering as such
  - Fix edge styling not applying
- Related fixes in Builder v2:
  - Fix excess spacing for nested node input fields

Other:
- "Retry" button in error view now reloads the page instead of
navigating to `/marketplace`

### 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:
  - CI for existing frontend UX flows
- [x] Updating to a new sub-agent version with compatibility issues: UX
flow works
- [x] Updating to a new sub-agent version with *no* compatibility
issues: works
  - [x] Designer approves of the look

---------

Co-authored-by: abhi1992002 <abhimanyu1992002@gmail.com>
Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
2026-01-14 13:25:20 +00:00
Zamil Majdy
61efee4139 fix(frontend): Remove hardcoded bypass of billing feature flag (#11762)
## Summary

Fixes a critical security issue where the billing button in the settings
sidebar was always visible to all users, bypassing the
`ENABLE_PLATFORM_PAYMENT` feature flag.

## Changes 🏗️

- Removed hardcoded `|| true` condition in
`frontend/src/app/(platform)/profile/(user)/layout.tsx:32` that was
bypassing the feature flag check
- The billing button is now properly gated by the
`ENABLE_PLATFORM_PAYMENT` feature flag as intended

## Root Cause

The `|| true` was accidentally left in commit
3dbc03e488 (PR #11617 - OAuth API & Single
Sign-On) from December 19, 2025. It was likely added temporarily during
development/testing to always show the billing button, but was not
removed before merging.

## Test Plan

1. Verify feature flag is set to disabled in LaunchDarkly
2. Navigate to settings page (`/profile/settings`)
3. Confirm billing button is NOT visible in the sidebar
4. Enable feature flag in LaunchDarkly
5. Refresh page and confirm billing button IS now visible
6. Verify billing page (`/profile/credits`) is still accessible via
direct URL when feature flag is disabled

## 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

Fixes SECRT-1791

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* The Billing link in the profile sidebar now respects the payment
feature flag configuration and will only display when payment
functionality is enabled.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-14 03:28:36 +00:00
43 changed files with 2400 additions and 171 deletions

View File

@@ -81,18 +81,16 @@ export const RunInputDialog = ({
Inputs Inputs
</Text> </Text>
</div> </div>
<div className="px-2"> <FormRenderer
<FormRenderer jsonSchema={inputSchema as RJSFSchema}
jsonSchema={inputSchema as RJSFSchema} handleChange={(v) => handleInputChange(v.formData)}
handleChange={(v) => handleInputChange(v.formData)} uiSchema={uiSchema}
uiSchema={uiSchema} initialValues={{}}
initialValues={{}} formContext={{
formContext={{ showHandles: false,
showHandles: false, size: "large",
size: "large", }}
}} />
/>
</div>
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@ import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/def
import { import {
useGetV1GetExecutionDetails, useGetV1GetExecutionDetails,
useGetV1GetSpecificGraph, useGetV1GetSpecificGraph,
useGetV1ListUserGraphs,
} from "@/app/api/__generated__/endpoints/graphs/graphs"; } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo"; import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { GraphModel } from "@/app/api/__generated__/models/graphModel"; import { GraphModel } from "@/app/api/__generated__/models/graphModel";
@@ -17,6 +18,7 @@ import { useReactFlow } from "@xyflow/react";
import { useControlPanelStore } from "../../../stores/controlPanelStore"; import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { useHistoryStore } from "../../../stores/historyStore"; import { useHistoryStore } from "../../../stores/historyStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { okData } from "@/app/api/helpers";
export const useFlow = () => { export const useFlow = () => {
const [isLocked, setIsLocked] = useState(false); const [isLocked, setIsLocked] = useState(false);
@@ -36,6 +38,9 @@ export const useFlow = () => {
const setGraphExecutionStatus = useGraphStore( const setGraphExecutionStatus = useGraphStore(
useShallow((state) => state.setGraphExecutionStatus), useShallow((state) => state.setGraphExecutionStatus),
); );
const setAvailableSubGraphs = useGraphStore(
useShallow((state) => state.setAvailableSubGraphs),
);
const updateEdgeBeads = useEdgeStore( const updateEdgeBeads = useEdgeStore(
useShallow((state) => state.updateEdgeBeads), useShallow((state) => state.updateEdgeBeads),
); );
@@ -62,6 +67,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( const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
flowID ?? "", flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {}, flowVersion !== null ? { version: flowVersion } : {},
@@ -116,10 +126,18 @@ export const useFlow = () => {
} }
}, [graph]); }, [graph]);
// Update available sub-graphs in store for sub-agent update detection
useEffect(() => {
if (availableGraphs) {
setAvailableSubGraphs(availableGraphs);
}
}, [availableGraphs, setAvailableSubGraphs]);
// adding nodes // adding nodes
useEffect(() => { useEffect(() => {
if (customNodes.length > 0) { if (customNodes.length > 0) {
useNodeStore.getState().setNodes([]); useNodeStore.getState().setNodes([]);
useNodeStore.getState().clearResolutionState();
addNodes(customNodes); addNodes(customNodes);
// Sync hardcoded values with handle IDs. // Sync hardcoded values with handle IDs.
@@ -203,6 +221,7 @@ export const useFlow = () => {
useEffect(() => { useEffect(() => {
return () => { return () => {
useNodeStore.getState().setNodes([]); useNodeStore.getState().setNodes([]);
useNodeStore.getState().clearResolutionState();
useEdgeStore.getState().setEdges([]); useEdgeStore.getState().setEdges([]);
useGraphStore.getState().reset(); useGraphStore.getState().reset();
useEdgeStore.getState().resetEdgeBeads(); useEdgeStore.getState().resetEdgeBeads();

View File

@@ -8,6 +8,7 @@ import {
getBezierPath, getBezierPath,
} from "@xyflow/react"; } from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { XIcon } from "@phosphor-icons/react"; import { XIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NodeExecutionResult } from "@/lib/autogpt-server-api"; import { NodeExecutionResult } from "@/lib/autogpt-server-api";
@@ -35,6 +36,8 @@ const CustomEdge = ({
selected, selected,
}: EdgeProps<CustomEdge>) => { }: EdgeProps<CustomEdge>) => {
const removeConnection = useEdgeStore((state) => state.removeEdge); const removeConnection = useEdgeStore((state) => state.removeEdge);
// Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [edgePath, labelX, labelY] = getBezierPath({ const [edgePath, labelX, labelY] = getBezierPath({
@@ -50,6 +53,12 @@ const CustomEdge = ({
const beadUp = data?.beadUp ?? 0; const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0; const beadDown = data?.beadDown ?? 0;
const handleRemoveEdge = () => {
removeConnection(id);
// Note: broken edge tracking is cleaned up automatically by useSubAgentUpdateState
// when it detects the edge no longer exists
};
return ( return (
<> <>
<BaseEdge <BaseEdge
@@ -57,9 +66,11 @@ const CustomEdge = ({
markerEnd={markerEnd} markerEnd={markerEnd}
className={cn( className={cn(
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]", isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
selected isBroken
? "stroke-zinc-800" ? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
: "stroke-zinc-500/50 hover:stroke-zinc-500", : selected
? "stroke-zinc-800"
: "stroke-zinc-500/50 hover:stroke-zinc-500",
)} )}
/> />
<JSBeads <JSBeads
@@ -70,12 +81,16 @@ const CustomEdge = ({
/> />
<EdgeLabelRenderer> <EdgeLabelRenderer>
<Button <Button
onClick={() => removeConnection(id)} onClick={handleRemoveEdge}
className={cn( className={cn(
"absolute h-fit min-w-0 p-1 transition-opacity", "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={{ style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: "all", pointerEvents: "all",

View File

@@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react";
import { useEdgeStore } from "../../../stores/edgeStore"; import { useEdgeStore } from "../../../stores/edgeStore";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers"; import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useNodeStore } from "../../../stores/nodeStore";
const InputNodeHandle = ({ const InputNodeHandle = ({
handleId, handleId,
@@ -15,6 +16,9 @@ const InputNodeHandle = ({
const isInputConnected = useEdgeStore((state) => const isInputConnected = useEdgeStore((state) =>
state.isInputConnected(nodeId ?? "", cleanedHandleId), state.isInputConnected(nodeId ?? "", cleanedHandleId),
); );
const isInputBroken = useNodeStore((state) =>
state.isInputBroken(nodeId, cleanedHandleId),
);
return ( return (
<Handle <Handle
@@ -27,7 +31,10 @@ const InputNodeHandle = ({
<CircleIcon <CircleIcon
size={16} size={16}
weight={isInputConnected ? "fill" : "duotone"} weight={isInputConnected ? "fill" : "duotone"}
className={"text-gray-400 opacity-100"} className={cn(
"text-gray-400 opacity-100",
isInputBroken && "text-red-500",
)}
/> />
</div> </div>
</Handle> </Handle>
@@ -38,14 +45,17 @@ const OutputNodeHandle = ({
field_name, field_name,
nodeId, nodeId,
hexColor, hexColor,
isBroken,
}: { }: {
field_name: string; field_name: string;
nodeId: string; nodeId: string;
hexColor: string; hexColor: string;
isBroken: boolean;
}) => { }) => {
const isOutputConnected = useEdgeStore((state) => const isOutputConnected = useEdgeStore((state) =>
state.isOutputConnected(nodeId, field_name), state.isOutputConnected(nodeId, field_name),
); );
return ( return (
<Handle <Handle
type={"source"} type={"source"}
@@ -58,7 +68,10 @@ const OutputNodeHandle = ({
size={16} size={16}
weight={"duotone"} weight={"duotone"}
color={isOutputConnected ? hexColor : "gray"} color={isOutputConnected ? hexColor : "gray"}
className={cn("text-gray-400 opacity-100")} className={cn(
"text-gray-400 opacity-100",
isBroken && "text-red-500",
)}
/> />
</div> </div>
</Handle> </Handle>

View File

@@ -20,6 +20,8 @@ import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
import { NodeRightClickMenu } from "./components/NodeRightClickMenu"; import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
import { StickyNoteBlock } from "./components/StickyNoteBlock"; import { StickyNoteBlock } from "./components/StickyNoteBlock";
import { WebhookDisclaimer } from "./components/WebhookDisclaimer"; import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
import { useCustomNode } from "./useCustomNode";
export type CustomNodeData = { export type CustomNodeData = {
hardcodedValues: { hardcodedValues: {
@@ -45,6 +47,10 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo( export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
({ data, id: nodeId, selected }) => { ({ data, id: nodeId, selected }) => {
const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
const isAgent = data.uiType === BlockUIType.AGENT;
if (data.uiType === BlockUIType.NOTE) { if (data.uiType === BlockUIType.NOTE) {
return ( return (
<StickyNoteBlock data={data} selected={selected} nodeId={nodeId} /> <StickyNoteBlock data={data} selected={selected} nodeId={nodeId} />
@@ -63,16 +69,6 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
const isAyrshare = data.uiType === BlockUIType.AYRSHARE; 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 = const hasConfigErrors =
data.errors && data.errors &&
Object.values(data.errors).some( Object.values(data.errors).some(
@@ -87,12 +83,11 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
const hasErrors = hasConfigErrors || hasOutputError; 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
const node = ( const node = (
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}> <NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
<div className="rounded-xlarge bg-white"> <div className="rounded-xlarge bg-white">
<NodeHeader data={data} nodeId={nodeId} /> <NodeHeader data={data} nodeId={nodeId} />
{isAgent && <SubAgentUpdateFeature nodeID={nodeId} nodeData={data} />}
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />} {isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
{isAyrshare && <AyrshareConnectButton />} {isAyrshare && <AyrshareConnectButton />}
<FormCreator <FormCreator

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 "./components/IncompatibleUpdateDialog";
import { ResolutionModeBar } from "./components/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,274 @@
import React from "react";
import {
WarningIcon,
XCircleIcon,
PlusCircleIcon,
} from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { beautifyString } from "@/lib/utils";
import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
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}
/>
)}
<Alert variant="warning">
<AlertDescription>
If you proceed, you&apos;ll need to remove the broken connections
before you can save or run your agent.
</AlertDescription>
</Alert>
<Dialog.Footer>
<Button variant="ghost" size="small" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={onConfirm}
className="border-amber-700 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/types";
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,194 @@
import { useState, useCallback, 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,
createUpdatedAgentNodeInputs,
getBrokenEdgeIDs,
} from "@/app/(platform)/build/hooks/useSubAgentUpdate";
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
import { CustomNodeData } from "../../CustomNode";
// Stable empty set to avoid creating new references in selectors
const EMPTY_SET: Set<string> = new Set();
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),
);
// Get this node's broken edge IDs from the per-node map
// Use EMPTY_SET as fallback to maintain referential stability
const brokenEdgeIDs = useNodeStore(
(state) => state.brokenEdgeIDs.get(nodeID) || EMPTY_SET,
);
const getNodeResolutionData = useNodeStore(
useShallow((state) => state.getNodeResolutionData),
);
const connectedEdges = useEdgeStore(
useShallow((state) => state.getNodeEdges(nodeID)),
);
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;
// Use the sub-agent update hook
const updateInfo = useSubAgentUpdate(
nodeID,
graphID,
graphVersion,
currentInputSchema,
currentOutputSchema,
connectedEdges,
availableSubGraphs,
);
const isInResolutionMode = isNodeInResolutionMode(nodeID);
// Handle update button click
const handleUpdateClick = useCallback(() => {
if (!updateInfo.hasUpdate || !updateInfo.latestGraph) return;
if (updateInfo.isCompatible) {
// Compatible update - apply directly
const newHardcodedValues = createUpdatedAgentNodeInputs(
nodeData.hardcodedValues,
updateInfo.latestGraph,
);
updateNodeData(nodeID, { hardcodedValues: newHardcodedValues });
} else {
// Incompatible update - show dialog
setShowIncompatibilityDialog(true);
}
}, [
updateInfo.hasUpdate,
updateInfo.latestGraph,
updateInfo.isCompatible,
nodeData.hardcodedValues,
updateNodeData,
nodeID,
]);
// Handle confirming an incompatible update
function handleConfirmIncompatibleUpdate() {
if (!updateInfo.latestGraph || !updateInfo.incompatibilities) return;
const latestGraph = updateInfo.latestGraph;
// Get the new schemas from the latest graph version
const newInputSchema =
(latestGraph.input_schema as Record<string, unknown>) || {};
const newOutputSchema =
(latestGraph.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 = createUpdatedAgentNodeInputs(
nodeData.hardcodedValues,
latestGraph,
);
// Get broken edge IDs and store them for this node
const brokenIds = getBrokenEdgeIDs(
connectedEdges,
updateInfo.incompatibilities,
nodeID,
);
setBrokenEdgeIDs(nodeID, 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);
}
// 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) =>
connectedEdges.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 will clean up this node's broken edges automatically
setNodeResolutionMode(nodeID, false);
}
}, [
isInResolutionMode,
brokenEdgeIDs,
connectedEdges,
resolutionData,
nodeID,
]);
return {
updateInfo,
isInResolutionMode,
resolutionData,
showIncompatibilityDialog,
setShowIncompatibilityDialog,
handleUpdateClick,
handleConfirmIncompatibleUpdate,
};
}

View File

@@ -1,4 +1,6 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeResolutionData } from "@/app/(platform)/build/stores/nodeStore";
import { RJSFSchema } from "@rjsf/utils";
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = { export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
INCOMPLETE: "ring-slate-300 bg-slate-300", INCOMPLETE: "ring-slate-300 bg-slate-300",
@@ -9,3 +11,48 @@ export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
TERMINATED: "ring-orange-300 bg-orange-300 ", TERMINATED: "ring-orange-300 bg-orange-300 ",
FAILED: "ring-red-300 bg-red-300", FAILED: "ring-red-300 bg-red-300",
}; };
/**
* Merges schemas during resolution mode to include removed inputs/outputs
* that still have connections, so users can see and delete them.
*/
export function mergeSchemaForResolution(
currentSchema: Record<string, unknown>,
newSchema: Record<string, unknown>,
resolutionData: NodeResolutionData,
type: "input" | "output",
): Record<string, unknown> {
const newProps = (newSchema.properties as RJSFSchema) || {};
const currentProps = (currentSchema.properties as RJSFSchema) || {};
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,
};
}

View File

@@ -0,0 +1,58 @@
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { CustomNodeData } from "./CustomNode";
import { BlockUIType } from "../../../types";
import { useMemo } from "react";
import { mergeSchemaForResolution } from "./helpers";
export const useCustomNode = ({
data,
nodeId,
}: {
data: CustomNodeData;
nodeId: string;
}) => {
const isInResolutionMode = useNodeStore((state) =>
state.nodesInResolutionMode.has(nodeId),
);
const resolutionData = useNodeStore((state) =>
state.nodeResolutionData.get(nodeId),
);
const isAgent = data.uiType === BlockUIType.AGENT;
const currentInputSchema = isAgent
? (data.hardcodedValues.input_schema ?? {})
: data.inputSchema;
const currentOutputSchema = isAgent
? (data.hardcodedValues.output_schema ?? {})
: data.outputSchema;
const inputSchema = useMemo(() => {
if (isAgent && isInResolutionMode && resolutionData) {
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) {
return mergeSchemaForResolution(
resolutionData.currentSchema.output_schema,
resolutionData.pendingUpdate.output_schema,
resolutionData,
"output",
);
}
return currentOutputSchema;
}, [isAgent, isInResolutionMode, resolutionData, currentOutputSchema]);
return {
inputSchema,
outputSchema,
};
};

View File

@@ -5,20 +5,16 @@ import { useNodeStore } from "../../../stores/nodeStore";
import { BlockUIType } from "../../types"; import { BlockUIType } from "../../types";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer"; import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
export const FormCreator = React.memo( interface FormCreatorProps {
({ jsonSchema: RJSFSchema;
jsonSchema, nodeId: string;
nodeId, uiType: BlockUIType;
uiType, showHandles?: boolean;
showHandles = true, className?: string;
className, }
}: {
jsonSchema: RJSFSchema; export const FormCreator: React.FC<FormCreatorProps> = React.memo(
nodeId: string; ({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
uiType: BlockUIType;
showHandles?: boolean;
className?: string;
}) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData); const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore( const getHardCodedValues = useNodeStore(

View File

@@ -14,6 +14,8 @@ import {
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { getTypeDisplayInfo } from "./helpers"; import { getTypeDisplayInfo } from "./helpers";
import { BlockUIType } from "../../types"; import { BlockUIType } from "../../types";
import { cn } from "@/lib/utils";
import { useBrokenOutputs } from "./useBrokenOutputs";
export const OutputHandler = ({ export const OutputHandler = ({
outputSchema, outputSchema,
@@ -27,6 +29,9 @@ export const OutputHandler = ({
const { isOutputConnected } = useEdgeStore(); const { isOutputConnected } = useEdgeStore();
const properties = outputSchema?.properties || {}; const properties = outputSchema?.properties || {};
const [isOutputVisible, setIsOutputVisible] = useState(true); const [isOutputVisible, setIsOutputVisible] = useState(true);
const brokenOutputs = useBrokenOutputs(nodeId);
console.log("brokenOutputs", brokenOutputs);
const showHandles = uiType !== BlockUIType.OUTPUT; const showHandles = uiType !== BlockUIType.OUTPUT;
@@ -44,6 +49,7 @@ export const OutputHandler = ({
const shouldShow = isConnected || isOutputVisible; const shouldShow = isConnected || isOutputVisible;
const { displayType, colorClass, hexColor } = const { displayType, colorClass, hexColor } =
getTypeDisplayInfo(fieldSchema); getTypeDisplayInfo(fieldSchema);
const isBroken = brokenOutputs.has(fullKey);
return shouldShow ? ( return shouldShow ? (
<div key={fullKey} className="flex flex-col items-end gap-2"> <div key={fullKey} className="flex flex-col items-end gap-2">
@@ -64,15 +70,29 @@ export const OutputHandler = ({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
<Text variant="body" className="text-slate-700"> <Text
variant="body"
className={cn(
"text-slate-700",
isBroken && "text-red-500 line-through",
)}
>
{fieldTitle} {fieldTitle}
</Text> </Text>
<Text variant="small" as="span" className={colorClass}> <Text
variant="small"
as="span"
className={cn(
colorClass,
isBroken && "!text-red-500 line-through",
)}
>
({displayType}) ({displayType})
</Text> </Text>
{showHandles && ( {showHandles && (
<OutputNodeHandle <OutputNodeHandle
isBroken={isBroken}
field_name={fullKey} field_name={fullKey}
nodeId={nodeId} nodeId={nodeId}
hexColor={hexColor} hexColor={hexColor}

View File

@@ -0,0 +1,23 @@
import { useMemo } from "react";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
/**
* Hook to get the set of broken output names for a node in resolution mode.
*/
export 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]);
}

View File

@@ -25,7 +25,7 @@ export const RightSidebar = () => {
> >
<div className="mb-4"> <div className="mb-4">
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200"> <h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200">
Flow Debug Panel Graph Debug Panel
</h2> </h2>
</div> </div>
@@ -65,7 +65,7 @@ export const RightSidebar = () => {
{l.source_id}[{l.source_name}] {l.sink_id}[{l.sink_name}] {l.source_id}[{l.source_name}] {l.sink_id}[{l.sink_name}]
</div> </div>
<div className="mt-1 text-slate-500 dark:text-slate-400"> <div className="mt-1 text-slate-500 dark:text-slate-400">
edge_id: {l.id} edge.id: {l.id}
</div> </div>
</div> </div>
))} ))}

View File

@@ -12,7 +12,14 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/__legacy__/ui/popover"; } from "@/components/__legacy__/ui/popover";
import { Block, BlockUIType, SpecialBlockID } from "@/lib/autogpt-server-api"; import {
Block,
BlockIORootSchema,
BlockUIType,
GraphInputSchema,
GraphOutputSchema,
SpecialBlockID,
} from "@/lib/autogpt-server-api";
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons"; import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
import { IconToyBrick } from "@/components/__legacy__/ui/icons"; import { IconToyBrick } from "@/components/__legacy__/ui/icons";
import { getPrimaryCategoryColor } from "@/lib/utils"; import { getPrimaryCategoryColor } from "@/lib/utils";
@@ -24,8 +31,10 @@ import {
import { GraphMeta } from "@/lib/autogpt-server-api"; import { GraphMeta } from "@/lib/autogpt-server-api";
import jaro from "jaro-winkler"; import jaro from "jaro-winkler";
type _Block = Block & { type _Block = Omit<Block, "inputSchema" | "outputSchema"> & {
uiKey?: string; uiKey?: string;
inputSchema: BlockIORootSchema | GraphInputSchema;
outputSchema: BlockIORootSchema | GraphOutputSchema;
hardcodedValues?: Record<string, any>; hardcodedValues?: Record<string, any>;
_cached?: { _cached?: {
blockName: string; blockName: string;

View File

@@ -2,7 +2,7 @@ import React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/__legacy__/ui/button"; import { Button } from "@/components/__legacy__/ui/button";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { ClockIcon } from "@phosphor-icons/react"; import { ClockIcon, WarningIcon } from "@phosphor-icons/react";
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons"; import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
interface Props { interface Props {
@@ -13,6 +13,7 @@ interface Props {
isRunning: boolean; isRunning: boolean;
isDisabled: boolean; isDisabled: boolean;
className?: string; className?: string;
resolutionModeActive?: boolean;
} }
export const BuildActionBar: React.FC<Props> = ({ export const BuildActionBar: React.FC<Props> = ({
@@ -23,9 +24,30 @@ export const BuildActionBar: React.FC<Props> = ({
isRunning, isRunning,
isDisabled, isDisabled,
className, className,
resolutionModeActive = false,
}) => { }) => {
const buttonClasses = const buttonClasses =
"flex items-center gap-2 text-sm font-medium md:text-lg"; "flex items-center gap-2 text-sm font-medium md:text-lg";
// Show resolution mode message instead of action buttons
if (resolutionModeActive) {
return (
<div
className={cn(
"flex w-fit select-none items-center justify-center p-4",
className,
)}
>
<div className="flex items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900/30">
<WarningIcon className="size-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
Remove incompatible connections to continue
</span>
</div>
</div>
);
}
return ( return (
<div <div
className={cn( className={cn(

View File

@@ -60,10 +60,16 @@ export function CustomEdge({
targetY - 5, targetY - 5,
); );
const { deleteElements } = useReactFlow<Node, CustomEdge>(); const { deleteElements } = useReactFlow<Node, CustomEdge>();
const { visualizeBeads } = useContext(BuilderContext) ?? { const builderContext = useContext(BuilderContext);
const { visualizeBeads } = builderContext ?? {
visualizeBeads: "no", visualizeBeads: "no",
}; };
// Check if this edge is broken (during resolution mode)
const isBroken =
builderContext?.resolutionMode?.active &&
builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
const onEdgeRemoveClick = () => { const onEdgeRemoveClick = () => {
deleteElements({ edges: [{ id }] }); deleteElements({ edges: [{ id }] });
}; };
@@ -171,12 +177,27 @@ export function CustomEdge({
const middle = getPointForT(0.5); const middle = getPointForT(0.5);
// Determine edge color - red for broken edges
const baseColor = data?.edgeColor ?? "#555555";
const edgeColor = isBroken ? "#ef4444" : baseColor;
// Add opacity to hex color (99 = 60% opacity, 80 = 50% opacity)
const strokeColor = isBroken
? `${edgeColor}99`
: selected
? edgeColor
: `${edgeColor}80`;
return ( return (
<> <>
<BaseEdge <BaseEdge
path={svgPath} path={svgPath}
markerEnd={markerEnd} markerEnd={markerEnd}
className={`data-sentry-unmask transition-all duration-200 ${data?.isStatic ? "[stroke-dasharray:5_3]" : "[stroke-dasharray:0]"} [stroke-width:${data?.isStatic ? 2.5 : 2}px] hover:[stroke-width:${data?.isStatic ? 3.5 : 3}px] ${selected ? `[stroke:${data?.edgeColor ?? "#555555"}]` : `[stroke:${data?.edgeColor ?? "#555555"}80] hover:[stroke:${data?.edgeColor ?? "#555555"}]`}`} style={{
stroke: strokeColor,
strokeWidth: data?.isStatic ? 2.5 : 2,
strokeDasharray: data?.isStatic ? "5 3" : undefined,
}}
className="data-sentry-unmask transition-all duration-200"
/> />
<path <path
d={svgPath} d={svgPath}

View File

@@ -18,6 +18,8 @@ import {
BlockIOSubSchema, BlockIOSubSchema,
BlockUIType, BlockUIType,
Category, Category,
GraphInputSchema,
GraphOutputSchema,
NodeExecutionResult, NodeExecutionResult,
} from "@/lib/autogpt-server-api"; } from "@/lib/autogpt-server-api";
import { import {
@@ -62,14 +64,21 @@ import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs";
import NodeOutputs from "../NodeOutputs"; import NodeOutputs from "../NodeOutputs";
import OutputModalComponent from "../OutputModalComponent"; import OutputModalComponent from "../OutputModalComponent";
import "./customnode.css"; import "./customnode.css";
import { SubAgentUpdateBar } from "./SubAgentUpdateBar";
import { IncompatibilityDialog } from "./IncompatibilityDialog";
import {
useSubAgentUpdate,
createUpdatedAgentNodeInputs,
getBrokenEdgeIDs,
} from "../../../hooks/useSubAgentUpdate";
export type ConnectionData = Array<{ export type ConnectedEdge = {
edge_id: string; id: string;
source: string; source: string;
sourceHandle: string; sourceHandle: string;
target: string; target: string;
targetHandle: string; targetHandle: string;
}>; };
export type CustomNodeData = { export type CustomNodeData = {
blockType: string; blockType: string;
@@ -80,7 +89,7 @@ export type CustomNodeData = {
inputSchema: BlockIORootSchema; inputSchema: BlockIORootSchema;
outputSchema: BlockIORootSchema; outputSchema: BlockIORootSchema;
hardcodedValues: { [key: string]: any }; hardcodedValues: { [key: string]: any };
connections: ConnectionData; connections: ConnectedEdge[];
isOutputOpen: boolean; isOutputOpen: boolean;
status?: NodeExecutionResult["status"]; status?: NodeExecutionResult["status"];
/** executionResults contains outputs across multiple executions /** executionResults contains outputs across multiple executions
@@ -127,20 +136,199 @@ export const CustomNode = React.memo(
let subGraphID = ""; let subGraphID = "";
if (data.uiType === BlockUIType.AGENT) {
// Display the graph's schema instead AgentExecutorBlock's schema.
data.inputSchema = data.hardcodedValues?.input_schema || {};
data.outputSchema = data.hardcodedValues?.output_schema || {};
subGraphID = data.hardcodedValues?.graph_id || subGraphID;
}
if (!builderContext) { if (!builderContext) {
throw new Error( throw new Error(
"BuilderContext consumer must be inside FlowEditor component", "BuilderContext consumer must be inside FlowEditor component",
); );
} }
const { libraryAgent, setIsAnyModalOpen, getNextNodeId } = builderContext; const {
libraryAgent,
setIsAnyModalOpen,
getNextNodeId,
availableFlows,
resolutionMode,
enterResolutionMode,
} = builderContext;
// Check if this node is in resolution mode (moved up for schema merge logic)
const isInResolutionMode =
resolutionMode.active && resolutionMode.nodeId === id;
if (data.uiType === BlockUIType.AGENT) {
// Display the graph's schema instead AgentExecutorBlock's schema.
const currentInputSchema = data.hardcodedValues?.input_schema || {};
const currentOutputSchema = data.hardcodedValues?.output_schema || {};
subGraphID = data.hardcodedValues?.graph_id || subGraphID;
// During resolution mode, merge old connected inputs/outputs with new schema
if (isInResolutionMode && resolutionMode.pendingUpdate) {
const newInputSchema =
(resolutionMode.pendingUpdate.input_schema as BlockIORootSchema) ||
{};
const newOutputSchema =
(resolutionMode.pendingUpdate.output_schema as BlockIORootSchema) ||
{};
// Merge input schemas: start with new schema, add old connected inputs that are missing
const mergedInputProps = { ...newInputSchema.properties };
const incomp = resolutionMode.incompatibilities;
if (incomp && currentInputSchema.properties) {
// Add back missing inputs that have connections (so user can see/delete them)
incomp.missingInputs.forEach((inputName) => {
if (currentInputSchema.properties[inputName]) {
mergedInputProps[inputName] =
currentInputSchema.properties[inputName];
}
});
// Add back inputs with type mismatches (keep old type so connection still works visually)
incomp.inputTypeMismatches.forEach((mismatch) => {
if (currentInputSchema.properties[mismatch.name]) {
mergedInputProps[mismatch.name] =
currentInputSchema.properties[mismatch.name];
}
});
}
// Merge output schemas: start with new schema, add old connected outputs that are missing
const mergedOutputProps = { ...newOutputSchema.properties };
if (incomp && currentOutputSchema.properties) {
incomp.missingOutputs.forEach((outputName) => {
if (currentOutputSchema.properties[outputName]) {
mergedOutputProps[outputName] =
currentOutputSchema.properties[outputName];
}
});
}
data.inputSchema = {
...newInputSchema,
properties: mergedInputProps,
};
data.outputSchema = {
...newOutputSchema,
properties: mergedOutputProps,
};
} else {
data.inputSchema = currentInputSchema;
data.outputSchema = currentOutputSchema;
}
}
const setHardcodedValues = useCallback(
(values: any) => {
updateNodeData(id, { hardcodedValues: values });
},
[id, updateNodeData],
);
// Sub-agent update detection
const isAgentBlock = data.uiType === BlockUIType.AGENT;
const graphId = isAgentBlock ? data.hardcodedValues?.graph_id : undefined;
const graphVersion = isAgentBlock
? data.hardcodedValues?.graph_version
: undefined;
const subAgentUpdate = useSubAgentUpdate(
id,
graphId,
graphVersion,
isAgentBlock
? (data.hardcodedValues?.input_schema as GraphInputSchema)
: undefined,
isAgentBlock
? (data.hardcodedValues?.output_schema as GraphOutputSchema)
: undefined,
data.connections,
availableFlows,
);
const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
useState(false);
// Helper to check if a handle is broken (for resolution mode)
const isInputHandleBroken = useCallback(
(handleName: string): boolean => {
if (!isInResolutionMode || !resolutionMode.incompatibilities) {
return false;
}
const incomp = resolutionMode.incompatibilities;
return (
incomp.missingInputs.includes(handleName) ||
incomp.inputTypeMismatches.some((m) => m.name === handleName)
);
},
[isInResolutionMode, resolutionMode.incompatibilities],
);
const isOutputHandleBroken = useCallback(
(handleName: string): boolean => {
if (!isInResolutionMode || !resolutionMode.incompatibilities) {
return false;
}
return resolutionMode.incompatibilities.missingOutputs.includes(
handleName,
);
},
[isInResolutionMode, resolutionMode.incompatibilities],
);
// Handle update button click
const handleUpdateClick = useCallback(() => {
if (!subAgentUpdate.latestGraph) return;
if (subAgentUpdate.isCompatible) {
// Compatible update - directly apply
const updatedValues = createUpdatedAgentNodeInputs(
data.hardcodedValues,
subAgentUpdate.latestGraph,
);
setHardcodedValues(updatedValues);
toast({
title: "Agent updated",
description: `Updated to version ${subAgentUpdate.latestVersion}`,
});
} else {
// Incompatible update - show dialog
setShowIncompatibilityDialog(true);
}
}, [subAgentUpdate, data.hardcodedValues, setHardcodedValues]);
// Handle confirm incompatible update
const handleConfirmIncompatibleUpdate = useCallback(() => {
if (!subAgentUpdate.latestGraph || !subAgentUpdate.incompatibilities) {
return;
}
// Create the updated values but DON'T apply them yet
const updatedValues = createUpdatedAgentNodeInputs(
data.hardcodedValues,
subAgentUpdate.latestGraph,
);
// Get broken edge IDs
const brokenEdgeIds = getBrokenEdgeIDs(
data.connections,
subAgentUpdate.incompatibilities,
id,
);
// Enter resolution mode with pending update (don't apply schema yet)
enterResolutionMode(
id,
subAgentUpdate.incompatibilities,
brokenEdgeIds,
updatedValues,
);
setShowIncompatibilityDialog(false);
}, [
subAgentUpdate,
data.hardcodedValues,
data.connections,
id,
enterResolutionMode,
]);
useEffect(() => { useEffect(() => {
if (data.executionResults || data.status) { if (data.executionResults || data.status) {
@@ -156,13 +344,6 @@ export const CustomNode = React.memo(
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen); setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]); }, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
const setHardcodedValues = useCallback(
(values: any) => {
updateNodeData(id, { hardcodedValues: values });
},
[id, updateNodeData],
);
const handleTitleEdit = useCallback(() => { const handleTitleEdit = useCallback(() => {
setIsEditingTitle(true); setIsEditingTitle(true);
setTimeout(() => { setTimeout(() => {
@@ -255,6 +436,7 @@ export const CustomNode = React.memo(
isConnected={isOutputHandleConnected(propKey)} isConnected={isOutputHandleConnected(propKey)}
schema={fieldSchema} schema={fieldSchema}
side="right" side="right"
isBroken={isOutputHandleBroken(propKey)}
/> />
{"properties" in fieldSchema && {"properties" in fieldSchema &&
renderHandles( renderHandles(
@@ -385,6 +567,7 @@ export const CustomNode = React.memo(
isRequired={isRequired} isRequired={isRequired}
schema={propSchema} schema={propSchema}
side="left" side="left"
isBroken={isInputHandleBroken(propKey)}
/> />
) : ( ) : (
propKey !== "credentials" && propKey !== "credentials" &&
@@ -873,6 +1056,22 @@ export const CustomNode = React.memo(
<ContextMenuContent /> <ContextMenuContent />
</div> </div>
{/* Sub-agent Update Bar - shown below header */}
{isAgentBlock && (subAgentUpdate.hasUpdate || isInResolutionMode) && (
<SubAgentUpdateBar
currentVersion={subAgentUpdate.currentVersion}
latestVersion={subAgentUpdate.latestVersion}
isCompatible={subAgentUpdate.isCompatible}
incompatibilities={
isInResolutionMode
? resolutionMode.incompatibilities
: subAgentUpdate.incompatibilities
}
onUpdate={handleUpdateClick}
isInResolutionMode={isInResolutionMode}
/>
)}
{/* Body */} {/* Body */}
<div className="mx-5 my-6 rounded-b-xl"> <div className="mx-5 my-6 rounded-b-xl">
{/* Input Handles */} {/* Input Handles */}
@@ -1044,9 +1243,24 @@ export const CustomNode = React.memo(
); );
return ( return (
<ContextMenu.Root> <>
<ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger> <ContextMenu.Root>
</ContextMenu.Root> <ContextMenu.Trigger>{nodeContent()}</ContextMenu.Trigger>
</ContextMenu.Root>
{/* Incompatibility Dialog for sub-agent updates */}
{isAgentBlock && subAgentUpdate.incompatibilities && (
<IncompatibilityDialog
isOpen={showIncompatibilityDialog}
onClose={() => setShowIncompatibilityDialog(false)}
onConfirm={handleConfirmIncompatibleUpdate}
currentVersion={subAgentUpdate.currentVersion}
latestVersion={subAgentUpdate.latestVersion}
agentName={data.blockType || "Agent"}
incompatibilities={subAgentUpdate.incompatibilities}
/>
)}
</>
); );
}, },
(prevProps, nextProps) => { (prevProps, nextProps) => {

View File

@@ -0,0 +1,244 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { Button } from "@/components/__legacy__/ui/button";
import { AlertTriangle, XCircle, PlusCircle } from "lucide-react";
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { beautifyString } from "@/lib/utils";
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
interface IncompatibilityDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
currentVersion: number;
latestVersion: number;
agentName: string;
incompatibilities: IncompatibilityInfo;
}
export const IncompatibilityDialog: React.FC<IncompatibilityDialogProps> = ({
isOpen,
onClose,
onConfirm,
currentVersion,
latestVersion,
agentName,
incompatibilities,
}) => {
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 open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Incompatible Update
</DialogTitle>
<DialogDescription>
Updating <strong>{beautifyString(agentName)}</strong> from v
{currentVersion} to v{latestVersion} will break some connections.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Input changes - two column layout */}
{hasInputChanges && (
<TwoColumnSection
title="Input Changes"
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
leftTitle="Removed"
leftItems={incompatibilities.missingInputs}
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
rightTitle="Added"
rightItems={incompatibilities.newInputs}
/>
)}
{/* Output changes - two column layout */}
{hasOutputChanges && (
<TwoColumnSection
title="Output Changes"
leftIcon={<XCircle className="h-4 w-4 text-red-500" />}
leftTitle="Removed"
leftItems={incompatibilities.missingOutputs}
rightIcon={<PlusCircle className="h-4 w-4 text-green-500" />}
rightTitle="Added"
rightItems={incompatibilities.newOutputs}
/>
)}
{hasTypeMismatches && (
<SingleColumnSection
icon={<XCircle className="h-4 w-4 text-red-500" />}
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={<PlusCircle className="h-4 w-4 text-amber-500" />}
title="New Required Inputs"
description="These inputs are now required:"
items={incompatibilities.newRequiredInputs}
/>
)}
</div>
<Alert variant="warning">
<AlertDescription>
If you proceed, you&apos;ll need to remove the broken connections
before you can save or run your agent.
</AlertDescription>
</Alert>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
className="bg-amber-600 hover:bg-amber-700"
>
Update Anyway
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
interface TwoColumnSectionProps {
title: string;
leftIcon: React.ReactNode;
leftTitle: string;
leftItems: string[];
rightIcon: React.ReactNode;
rightTitle: string;
rightItems: string[];
}
const TwoColumnSection: React.FC<TwoColumnSectionProps> = ({
title,
leftIcon,
leftTitle,
leftItems,
rightIcon,
rightTitle,
rightItems,
}) => (
<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>
);
interface SingleColumnSectionProps {
icon: React.ReactNode;
title: string;
description: string;
items: string[];
}
const SingleColumnSection: React.FC<SingleColumnSectionProps> = ({
icon,
title,
description,
items,
}) => (
<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>
);
export default IncompatibilityDialog;

View File

@@ -0,0 +1,130 @@
import React from "react";
import { Button } from "@/components/__legacy__/ui/button";
import { ArrowUp, AlertTriangle, Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { cn } from "@/lib/utils";
interface SubAgentUpdateBarProps {
currentVersion: number;
latestVersion: number;
isCompatible: boolean;
incompatibilities: IncompatibilityInfo | null;
onUpdate: () => void;
isInResolutionMode?: boolean;
}
export const SubAgentUpdateBar: React.FC<SubAgentUpdateBarProps> = ({
currentVersion,
latestVersion,
isCompatible,
incompatibilities,
onUpdate,
isInResolutionMode = false,
}) => {
if (isInResolutionMode) {
return <ResolutionModeBar incompatibilities={incompatibilities} />;
}
return (
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-blue-50 px-3 py-2 dark:bg-blue-900/30">
<div className="flex items-center gap-2">
<ArrowUp 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>
<AlertTriangle 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="sm"
variant={isCompatible ? "default" : "outline"}
onClick={onUpdate}
className={cn(
"h-7 text-xs",
!isCompatible && "border-amber-500 text-amber-600 hover:bg-amber-50",
)}
>
Update
</Button>
</div>
);
};
interface ResolutionModeBarProps {
incompatibilities: IncompatibilityInfo | null;
}
const ResolutionModeBar: React.FC<ResolutionModeBarProps> = ({
incompatibilities,
}) => {
const formatIncompatibilities = () => {
if (!incompatibilities) return "No incompatibilities";
const items: string[] = [];
if (incompatibilities.missingInputs.length > 0) {
items.push(
`Missing inputs: ${incompatibilities.missingInputs.join(", ")}`,
);
}
if (incompatibilities.missingOutputs.length > 0) {
items.push(
`Missing outputs: ${incompatibilities.missingOutputs.join(", ")}`,
);
}
if (incompatibilities.newRequiredInputs.length > 0) {
items.push(
`New required inputs: ${incompatibilities.newRequiredInputs.join(", ")}`,
);
}
if (incompatibilities.inputTypeMismatches.length > 0) {
const mismatches = incompatibilities.inputTypeMismatches
.map((m) => `${m.name} (${m.oldType}${m.newType})`)
.join(", ");
items.push(`Type changed: ${mismatches}`);
}
return items.join("\n");
};
return (
<div className="flex items-center justify-between gap-2 rounded-t-lg bg-amber-50 px-3 py-2 dark:bg-amber-900/30">
<div className="flex items-center gap-2">
<AlertTriangle 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>
<Info className="h-4 w-4 cursor-help text-amber-500" />
</TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-line">
<p className="font-medium">Incompatible changes:</p>
<p className="mt-1 text-xs">{formatIncompatibilities()}</p>
<p className="mt-2 text-xs text-gray-400">
Delete the red connections to continue
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
};
export default SubAgentUpdateBar;

View File

@@ -26,15 +26,17 @@ import {
applyNodeChanges, applyNodeChanges,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { CustomNode } from "../CustomNode/CustomNode"; import { ConnectedEdge, CustomNode } from "../CustomNode/CustomNode";
import "./flow.css"; import "./flow.css";
import { import {
BlockUIType, BlockUIType,
formatEdgeID, formatEdgeID,
GraphExecutionID, GraphExecutionID,
GraphID, GraphID,
GraphMeta,
LibraryAgent, LibraryAgent,
} from "@/lib/autogpt-server-api"; } from "@/lib/autogpt-server-api";
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { Key, storage } from "@/services/storage/local-storage"; import { Key, storage } from "@/services/storage/local-storage";
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils"; import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
import { history } from "../history"; import { history } from "../history";
@@ -72,12 +74,30 @@ import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block // It helps to prevent spamming the history with small movements especially when pressing on a input in a block
const MINIMUM_MOVE_BEFORE_LOG = 50; const MINIMUM_MOVE_BEFORE_LOG = 50;
export type ResolutionModeState = {
active: boolean;
nodeId: string | null;
incompatibilities: IncompatibilityInfo | null;
brokenEdgeIds: string[];
pendingUpdate: Record<string, unknown> | null; // The hardcoded values to apply after resolution
};
type BuilderContextType = { type BuilderContextType = {
libraryAgent: LibraryAgent | null; libraryAgent: LibraryAgent | null;
visualizeBeads: "no" | "static" | "animate"; visualizeBeads: "no" | "static" | "animate";
setIsAnyModalOpen: (isOpen: boolean) => void; setIsAnyModalOpen: (isOpen: boolean) => void;
getNextNodeId: () => string; getNextNodeId: () => string;
getNodeTitle: (nodeID: string) => string | null; getNodeTitle: (nodeID: string) => string | null;
availableFlows: GraphMeta[];
resolutionMode: ResolutionModeState;
enterResolutionMode: (
nodeId: string,
incompatibilities: IncompatibilityInfo,
brokenEdgeIds: string[],
pendingUpdate: Record<string, unknown>,
) => void;
exitResolutionMode: () => void;
applyPendingUpdate: () => void;
}; };
export type NodeDimension = { export type NodeDimension = {
@@ -172,6 +192,92 @@ const FlowEditor: React.FC<{
// It stores the dimension of all nodes with position as well // It stores the dimension of all nodes with position as well
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({}); const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});
// Resolution mode state for sub-agent incompatible updates
const [resolutionMode, setResolutionMode] = useState<ResolutionModeState>({
active: false,
nodeId: null,
incompatibilities: null,
brokenEdgeIds: [],
pendingUpdate: null,
});
const enterResolutionMode = useCallback(
(
nodeId: string,
incompatibilities: IncompatibilityInfo,
brokenEdgeIds: string[],
pendingUpdate: Record<string, unknown>,
) => {
setResolutionMode({
active: true,
nodeId,
incompatibilities,
brokenEdgeIds,
pendingUpdate,
});
},
[],
);
const exitResolutionMode = useCallback(() => {
setResolutionMode({
active: false,
nodeId: null,
incompatibilities: null,
brokenEdgeIds: [],
pendingUpdate: null,
});
}, []);
// Apply pending update after resolution mode completes
const applyPendingUpdate = useCallback(() => {
if (!resolutionMode.nodeId || !resolutionMode.pendingUpdate) return;
const node = nodes.find((n) => n.id === resolutionMode.nodeId);
if (node) {
const pendingUpdate = resolutionMode.pendingUpdate as {
[key: string]: any;
};
setNodes((nds) =>
nds.map((n) =>
n.id === resolutionMode.nodeId
? { ...n, data: { ...n.data, hardcodedValues: pendingUpdate } }
: n,
),
);
}
exitResolutionMode();
toast({
title: "Update complete",
description: "Agent has been updated to the new version.",
});
}, [resolutionMode, nodes, setNodes, exitResolutionMode, toast]);
// Check if all broken edges have been removed and auto-apply pending update
useEffect(() => {
if (!resolutionMode.active || resolutionMode.brokenEdgeIds.length === 0) {
return;
}
const currentEdgeIds = new Set(edges.map((e) => e.id));
const remainingBrokenEdges = resolutionMode.brokenEdgeIds.filter((id) =>
currentEdgeIds.has(id),
);
if (remainingBrokenEdges.length === 0) {
// All broken edges have been removed, apply pending update
applyPendingUpdate();
} else if (
remainingBrokenEdges.length !== resolutionMode.brokenEdgeIds.length
) {
// Update the list of broken edges
setResolutionMode((prev) => ({
...prev,
brokenEdgeIds: remainingBrokenEdges,
}));
}
}, [edges, resolutionMode, applyPendingUpdate]);
// Set page title with or without graph name // Set page title with or without graph name
useEffect(() => { useEffect(() => {
document.title = savedAgent document.title = savedAgent
@@ -431,17 +537,19 @@ const FlowEditor: React.FC<{
...node.data.connections.filter( ...node.data.connections.filter(
(conn) => (conn) =>
!removedEdges.some( !removedEdges.some(
(removedEdge) => removedEdge.id === conn.edge_id, (removedEdge) => removedEdge.id === conn.id,
), ),
), ),
// Add node connections for added edges // Add node connections for added edges
...addedEdges.map((addedEdge) => ({ ...addedEdges.map(
edge_id: addedEdge.item.id, (addedEdge): ConnectedEdge => ({
source: addedEdge.item.source, id: addedEdge.item.id,
target: addedEdge.item.target, source: addedEdge.item.source,
sourceHandle: addedEdge.item.sourceHandle!, target: addedEdge.item.target,
targetHandle: addedEdge.item.targetHandle!, sourceHandle: addedEdge.item.sourceHandle!,
})), targetHandle: addedEdge.item.targetHandle!,
}),
),
], ],
}, },
})); }));
@@ -467,13 +575,15 @@ const FlowEditor: React.FC<{
data: { data: {
...node.data, ...node.data,
connections: [ connections: [
...replaceEdges.map((replaceEdge) => ({ ...replaceEdges.map(
edge_id: replaceEdge.item.id, (replaceEdge): ConnectedEdge => ({
source: replaceEdge.item.source, id: replaceEdge.item.id,
target: replaceEdge.item.target, source: replaceEdge.item.source,
sourceHandle: replaceEdge.item.sourceHandle!, target: replaceEdge.item.target,
targetHandle: replaceEdge.item.targetHandle!, sourceHandle: replaceEdge.item.sourceHandle!,
})), targetHandle: replaceEdge.item.targetHandle!,
}),
),
], ],
}, },
})), })),
@@ -890,8 +1000,23 @@ const FlowEditor: React.FC<{
setIsAnyModalOpen, setIsAnyModalOpen,
getNextNodeId, getNextNodeId,
getNodeTitle, getNodeTitle,
availableFlows,
resolutionMode,
enterResolutionMode,
exitResolutionMode,
applyPendingUpdate,
}), }),
[libraryAgent, visualizeBeads, getNextNodeId, getNodeTitle], [
libraryAgent,
visualizeBeads,
getNextNodeId,
getNodeTitle,
availableFlows,
resolutionMode,
enterResolutionMode,
applyPendingUpdate,
exitResolutionMode,
],
); );
return ( return (
@@ -991,6 +1116,7 @@ const FlowEditor: React.FC<{
onClickScheduleButton={handleScheduleButton} onClickScheduleButton={handleScheduleButton}
isDisabled={!savedAgent} isDisabled={!savedAgent}
isRunning={isRunning} isRunning={isRunning}
resolutionModeActive={resolutionMode.active}
/> />
) : ( ) : (
<Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none"> <Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none">

View File

@@ -1,6 +1,11 @@
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types"; import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils"; import {
import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils"; cn,
beautifyString,
getTypeBgColor,
getTypeTextColor,
getEffectiveType,
} from "@/lib/utils";
import { FC, memo, useCallback } from "react"; import { FC, memo, useCallback } from "react";
import { Handle, Position } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip"; import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
@@ -13,6 +18,7 @@ type HandleProps = {
side: "left" | "right"; side: "left" | "right";
title?: string; title?: string;
className?: string; className?: string;
isBroken?: boolean;
}; };
// Move the constant out of the component to avoid re-creation on every render. // Move the constant out of the component to avoid re-creation on every render.
@@ -27,18 +33,23 @@ const TYPE_NAME: Record<string, string> = {
}; };
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily. // Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
const Dot: FC<{ isConnected: boolean; type?: string }> = memo( const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> =
({ isConnected, type }) => { memo(({ isConnected, type, isBroken }) => {
const color = isConnected const color = isBroken
? getTypeBgColor(type || "any") ? "border-red-500 bg-red-100 dark:bg-red-900/30"
: "border-gray-300 dark:border-gray-600"; : isConnected
? getTypeBgColor(type || "any")
: "border-gray-300 dark:border-gray-600";
return ( return (
<div <div
className={`${color} m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700`} className={cn(
"m-1 h-4 w-4 rounded-full border-2 bg-white transition-colors duration-100 group-hover:bg-gray-300 dark:bg-slate-800 dark:group-hover:bg-gray-700",
color,
isBroken && "opacity-50",
)}
/> />
); );
}, });
);
Dot.displayName = "Dot"; Dot.displayName = "Dot";
const NodeHandle: FC<HandleProps> = ({ const NodeHandle: FC<HandleProps> = ({
@@ -49,24 +60,34 @@ const NodeHandle: FC<HandleProps> = ({
side, side,
title, title,
className, className,
isBroken = false,
}) => { }) => {
const typeClass = `text-sm ${getTypeTextColor(schema.type || "any")} ${ // Extract effective type from schema (handles anyOf/oneOf/allOf wrappers)
const effectiveType = getEffectiveType(schema);
const typeClass = `text-sm ${getTypeTextColor(effectiveType || "any")} ${
side === "left" ? "text-left" : "text-right" side === "left" ? "text-left" : "text-right"
}`; }`;
const label = ( const label = (
<div className="flex flex-grow flex-row"> <div className={cn("flex flex-grow flex-row", isBroken && "opacity-50")}>
<span <span
className={cn( className={cn(
"data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100", "data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
className, className,
isBroken && "text-red-500 line-through",
)} )}
> >
{title || schema.title || beautifyString(keyName.toLowerCase())} {title || schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""} {isRequired ? "*" : ""}
</span> </span>
<span className={`${typeClass} data-sentry-unmask flex items-end`}> <span
({TYPE_NAME[schema.type as keyof typeof TYPE_NAME] || "any"}) className={cn(
`${typeClass} data-sentry-unmask flex items-end`,
isBroken && "text-red-400",
)}
>
({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
</span> </span>
</div> </div>
); );
@@ -84,7 +105,7 @@ const NodeHandle: FC<HandleProps> = ({
return ( return (
<div <div
key={keyName} key={keyName}
className="handle-container" className={cn("handle-container", isBroken && "pointer-events-none")}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<Handle <Handle
@@ -92,10 +113,15 @@ const NodeHandle: FC<HandleProps> = ({
data-testid={`input-handle-${keyName}`} data-testid={`input-handle-${keyName}`}
position={Position.Left} position={Position.Left}
id={keyName} id={keyName}
className="group -ml-[38px]" className={cn("group -ml-[38px]", isBroken && "cursor-not-allowed")}
isConnectable={!isBroken}
> >
<div className="pointer-events-none flex items-center"> <div className="pointer-events-none flex items-center">
<Dot isConnected={isConnected} type={schema.type} /> <Dot
isConnected={isConnected}
type={effectiveType}
isBroken={isBroken}
/>
{label} {label}
</div> </div>
</Handle> </Handle>
@@ -106,7 +132,10 @@ const NodeHandle: FC<HandleProps> = ({
return ( return (
<div <div
key={keyName} key={keyName}
className="handle-container justify-end" className={cn(
"handle-container justify-end",
isBroken && "pointer-events-none",
)}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<Handle <Handle
@@ -114,11 +143,16 @@ const NodeHandle: FC<HandleProps> = ({
data-testid={`output-handle-${keyName}`} data-testid={`output-handle-${keyName}`}
position={Position.Right} position={Position.Right}
id={keyName} id={keyName}
className="group -mr-[38px]" className={cn("group -mr-[38px]", isBroken && "cursor-not-allowed")}
isConnectable={!isBroken}
> >
<div className="pointer-events-none flex items-center"> <div className="pointer-events-none flex items-center">
{label} {label}
<Dot isConnected={isConnected} type={schema.type} /> <Dot
isConnected={isConnected}
type={effectiveType}
isBroken={isBroken}
/>
</div> </div>
</Handle> </Handle>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { import {
ConnectionData, ConnectedEdge,
CustomNodeData, CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode"; } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput"; import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
@@ -65,7 +65,7 @@ type NodeObjectInputTreeProps = {
selfKey?: string; selfKey?: string;
schema: BlockIORootSchema | BlockIOObjectSubSchema; schema: BlockIORootSchema | BlockIOObjectSubSchema;
object?: { [key: string]: any }; object?: { [key: string]: any };
connections: ConnectionData; connections: ConnectedEdge[];
handleInputClick: (key: string) => void; handleInputClick: (key: string) => void;
handleInputChange: (key: string, value: any) => void; handleInputChange: (key: string, value: any) => void;
errors: { [key: string]: string | undefined }; errors: { [key: string]: string | undefined };
@@ -585,7 +585,7 @@ const NodeOneOfDiscriminatorField: FC<{
currentValue?: any; currentValue?: any;
defaultValue?: any; defaultValue?: any;
errors: { [key: string]: string | undefined }; errors: { [key: string]: string | undefined };
connections: ConnectionData; connections: ConnectedEdge[];
handleInputChange: (key: string, value: any) => void; handleInputChange: (key: string, value: any) => void;
handleInputClick: (key: string) => void; handleInputClick: (key: string) => void;
className?: string; className?: string;

View File

@@ -1,15 +1,16 @@
import { FC, useCallback, useEffect, useState } from "react"; import { FC, useCallback, useEffect, useState } from "react";
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle"; import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
import { import type {
BlockIOTableSubSchema, BlockIOTableSubSchema,
TableCellValue, TableCellValue,
TableRow, TableRow,
} from "@/lib/autogpt-server-api/types"; } from "@/lib/autogpt-server-api/types";
import type { ConnectedEdge } from "./CustomNode/CustomNode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PlusIcon, XIcon } from "@phosphor-icons/react"; import { PlusIcon, XIcon } from "@phosphor-icons/react";
import { Button } from "../../../../../components/atoms/Button/Button"; import { Button } from "@/components/atoms/Button/Button";
import { Input } from "../../../../../components/atoms/Input/Input"; import { Input } from "@/components/atoms/Input/Input";
interface NodeTableInputProps { interface NodeTableInputProps {
/** Unique identifier for the node in the builder graph */ /** Unique identifier for the node in the builder graph */
@@ -25,13 +26,7 @@ interface NodeTableInputProps {
/** Validation errors mapped by field key */ /** Validation errors mapped by field key */
errors: { [key: string]: string | undefined }; errors: { [key: string]: string | undefined };
/** Graph connections between nodes in the builder */ /** Graph connections between nodes in the builder */
connections: { connections: ConnectedEdge[];
edge_id: string;
source: string;
sourceHandle: string;
target: string;
targetHandle: string;
}[];
/** Callback when table data changes */ /** Callback when table data changes */
handleInputChange: (key: string, value: TableRow[]) => void; handleInputChange: (key: string, value: TableRow[]) => void;
/** Callback when input field is clicked (for builder selection) */ /** Callback when input field is clicked (for builder selection) */

View File

@@ -1,6 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Node, Edge, useReactFlow } from "@xyflow/react"; import { Node, Edge, useReactFlow } from "@xyflow/react";
import { Key, storage } from "@/services/storage/local-storage"; import { Key, storage } from "@/services/storage/local-storage";
import { ConnectedEdge } from "./CustomNode/CustomNode";
interface CopyableData { interface CopyableData {
nodes: Node[]; nodes: Node[];
@@ -111,13 +112,15 @@ export function useCopyPaste(getNextNodeId: () => string) {
(edge: Edge) => (edge: Edge) =>
edge.source === node.id || edge.target === node.id, edge.source === node.id || edge.target === node.id,
) )
.map((edge: Edge) => ({ .map(
edge_id: edge.id, (edge: Edge): ConnectedEdge => ({
source: edge.source, id: edge.id,
target: edge.target, source: edge.source,
sourceHandle: edge.sourceHandle, target: edge.target,
targetHandle: edge.targetHandle, sourceHandle: edge.sourceHandle!,
})); targetHandle: edge.targetHandle!,
}),
);
return { return {
...node, ...node,

View File

@@ -0,0 +1,104 @@
import { GraphInputSchema } from "@/lib/autogpt-server-api";
import { GraphMetaLike, IncompatibilityInfo } from "./types";
// 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
export 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 {};
}
export function getSchemaRequired(schema: unknown): SchemaRequired {
if (
schema &&
typeof schema === "object" &&
"required" in schema &&
Array.isArray(schema.required)
) {
return schema.required as SchemaRequired;
}
return [];
}
/**
* Creates the updated agent node inputs for a sub-agent node
*/
export function createUpdatedAgentNodeInputs(
currentInputs: Record<string, unknown>,
latestSubGraphVersion: GraphMetaLike,
): Record<string, unknown> {
return {
...currentInputs,
graph_version: latestSubGraphVersion.version,
input_schema: latestSubGraphVersion.input_schema,
output_schema: latestSubGraphVersion.output_schema,
};
}
/** Generic edge type that works with both builders:
* - New builder uses CustomEdge with (formally) optional handles
* - Legacy builder uses ConnectedEdge type with required handles */
export type EdgeLike = {
id: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
};
/**
* Determines which edges are broken after an incompatible update.
* Works with both legacy ConnectedEdge and new CustomEdge.
*/
export function getBrokenEdgeIDs(
connections: EdgeLike[],
incompatibilities: IncompatibilityInfo,
nodeID: string,
): string[] {
const brokenEdgeIDs: string[] = [];
const typeMismatchInputNames = new Set(
incompatibilities.inputTypeMismatches.map((m) => m.name),
);
connections.forEach((conn) => {
// 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.id);
}
// Check if this connection uses an input with a type mismatch (node is target)
if (
conn.target === nodeID &&
conn.targetHandle &&
typeMismatchInputNames.has(conn.targetHandle)
) {
brokenEdgeIDs.push(conn.id);
}
// Check if this connection uses a missing output (node is source)
if (
conn.source === nodeID &&
conn.sourceHandle &&
incompatibilities.missingOutputs.includes(conn.sourceHandle)
) {
brokenEdgeIDs.push(conn.id);
}
});
return brokenEdgeIDs;
}

View File

@@ -0,0 +1,2 @@
export { useSubAgentUpdate } from "./useSubAgentUpdate";
export { createUpdatedAgentNodeInputs, getBrokenEdgeIDs } from "./helpers";

View File

@@ -0,0 +1,27 @@
import type { GraphMeta as LegacyGraphMeta } from "@/lib/autogpt-server-api";
import type { GraphMeta as GeneratedGraphMeta } from "@/app/api/__generated__/models/graphMeta";
export type SubAgentUpdateInfo<T extends GraphMetaLike = GraphMetaLike> = {
hasUpdate: boolean;
currentVersion: number;
latestVersion: number;
latestGraph: T | null;
isCompatible: boolean;
incompatibilities: IncompatibilityInfo | null;
};
// Union type for GraphMeta that works with both legacy and new builder
export type GraphMetaLike = LegacyGraphMeta | GeneratedGraphMeta;
export type IncompatibilityInfo = {
missingInputs: string[]; // Connected inputs that no longer exist
missingOutputs: string[]; // Connected outputs that no longer exist
newInputs: string[]; // Inputs that exist in new version but not in current
newOutputs: string[]; // Outputs that exist in new version but not in current
newRequiredInputs: string[]; // New required inputs not in current version or not required
inputTypeMismatches: Array<{
name: string;
oldType: string;
newType: string;
}>; // Connected inputs where the type has changed
};

View File

@@ -0,0 +1,160 @@
import { useMemo } from "react";
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
import { getEffectiveType } from "@/lib/utils";
import { EdgeLike, getSchemaProperties, getSchemaRequired } from "./helpers";
import {
GraphMetaLike,
IncompatibilityInfo,
SubAgentUpdateInfo,
} from "./types";
/**
* Checks if a newer version of a sub-agent is available and determines compatibility
*/
export function useSubAgentUpdate<T extends GraphMetaLike>(
nodeID: string,
graphID: string | undefined,
graphVersion: number | undefined,
currentInputSchema: GraphInputSchema | undefined,
currentOutputSchema: GraphOutputSchema | undefined,
connections: EdgeLike[],
availableGraphs: T[],
): SubAgentUpdateInfo<T> {
// Find the latest version of the same graph
const latestGraph = useMemo(() => {
if (!graphID) return null;
return availableGraphs.find((graph) => graph.id === graphID) || null;
}, [graphID, availableGraphs]);
// Check if there's an update available
const hasUpdate = useMemo(() => {
if (!latestGraph || graphVersion === undefined) return false;
return latestGraph.version! > graphVersion;
}, [latestGraph, graphVersion]);
// Get connected input and output handles for this specific node
const connectedHandles = useMemo(() => {
const inputHandles = new Set<string>();
const outputHandles = new Set<string>();
connections.forEach((conn) => {
// If this node is the target, the targetHandle is an input on this node
if (conn.target === nodeID && conn.targetHandle) {
inputHandles.add(conn.targetHandle);
}
// If this node is the source, the sourceHandle is an output on this node
if (conn.source === nodeID && conn.sourceHandle) {
outputHandles.add(conn.sourceHandle);
}
});
return { inputHandles, outputHandles };
}, [connections, nodeID]);
// Check schema compatibility
const compatibilityResult = useMemo((): {
isCompatible: boolean;
incompatibilities: IncompatibilityInfo | null;
} => {
if (!hasUpdate || !latestGraph) {
return { isCompatible: true, incompatibilities: null };
}
const newInputProps = getSchemaProperties(latestGraph.input_schema);
const newOutputProps = getSchemaProperties(latestGraph.output_schema);
const newRequiredInputs = getSchemaRequired(latestGraph.input_schema);
const currentInputProps = getSchemaProperties(currentInputSchema);
const currentOutputProps = getSchemaProperties(currentOutputSchema);
const currentRequiredInputs = getSchemaRequired(currentInputSchema);
const incompatibilities: IncompatibilityInfo = {
missingInputs: [],
missingOutputs: [],
newInputs: [],
newOutputs: [],
newRequiredInputs: [],
inputTypeMismatches: [],
};
// Check for missing connected inputs and type mismatches
connectedHandles.inputHandles.forEach((inputHandle) => {
if (!(inputHandle in newInputProps)) {
incompatibilities.missingInputs.push(inputHandle);
} else {
// Check for type mismatch on connected inputs
const currentProp = currentInputProps[inputHandle];
const newProp = newInputProps[inputHandle];
const currentType = getEffectiveType(currentProp);
const newType = getEffectiveType(newProp);
if (currentType && newType && currentType !== newType) {
incompatibilities.inputTypeMismatches.push({
name: inputHandle,
oldType: currentType,
newType: newType,
});
}
}
});
// Check for missing connected outputs
connectedHandles.outputHandles.forEach((outputHandle) => {
if (!(outputHandle in newOutputProps)) {
incompatibilities.missingOutputs.push(outputHandle);
}
});
// Check for new required inputs that didn't exist or weren't required before
newRequiredInputs.forEach((requiredInput) => {
const existedBefore = requiredInput in currentInputProps;
const wasRequiredBefore = currentRequiredInputs.includes(
requiredInput as string,
);
if (!existedBefore || !wasRequiredBefore) {
incompatibilities.newRequiredInputs.push(requiredInput as string);
}
});
// Check for new inputs that don't exist in the current version
Object.keys(newInputProps).forEach((inputName) => {
if (!(inputName in currentInputProps)) {
incompatibilities.newInputs.push(inputName);
}
});
// Check for new outputs that don't exist in the current version
Object.keys(newOutputProps).forEach((outputName) => {
if (!(outputName in currentOutputProps)) {
incompatibilities.newOutputs.push(outputName);
}
});
const hasIncompatibilities =
incompatibilities.missingInputs.length > 0 ||
incompatibilities.missingOutputs.length > 0 ||
incompatibilities.newRequiredInputs.length > 0 ||
incompatibilities.inputTypeMismatches.length > 0;
return {
isCompatible: !hasIncompatibilities,
incompatibilities: hasIncompatibilities ? incompatibilities : null,
};
}, [
hasUpdate,
latestGraph,
currentInputSchema,
currentOutputSchema,
connectedHandles,
]);
return {
hasUpdate,
currentVersion: graphVersion || 0,
latestVersion: latestGraph?.version || 0,
latestGraph,
isCompatible: compatibilityResult.isCompatible,
incompatibilities: compatibilityResult.incompatibilities,
};
}

View File

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

View File

@@ -17,6 +17,25 @@ import {
ensurePathExists, ensurePathExists,
parseHandleIdToPath, parseHandleIdToPath,
} from "@/components/renderers/InputRenderer/helpers"; } from "@/components/renderers/InputRenderer/helpers";
import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types";
// 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 // Minimum movement (in pixels) required before logging position change to history
// Prevents spamming history with small movements when clicking on inputs inside blocks // Prevents spamming history with small movements when clicking on inputs inside blocks
@@ -65,12 +84,32 @@ type NodeStore = {
backendId: string, backendId: string,
errors: { [key: string]: string }, errors: { [key: string]: string },
) => void; ) => void;
clearAllNodeErrors: () => void; // Add this
syncHardcodedValuesWithHandleIds: (nodeId: string) => void; syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
// Credentials optional helpers
setCredentialsOptional: (nodeId: string, optional: boolean) => void; setCredentialsOptional: (nodeId: string, optional: boolean) => void;
clearAllNodeErrors: () => void;
nodesInResolutionMode: Set<string>;
brokenEdgeIDs: Map<string, Set<string>>;
nodeResolutionData: Map<string, NodeResolutionData>;
setNodeResolutionMode: (
nodeID: string,
inResolution: boolean,
resolutionData?: NodeResolutionData,
) => void;
isNodeInResolutionMode: (nodeID: string) => boolean;
getNodeResolutionData: (nodeID: string) => NodeResolutionData | undefined;
setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => void;
removeBrokenEdgeID: (nodeID: string, edgeID: string) => void;
isEdgeBroken: (edgeID: string) => boolean;
clearResolutionState: () => void;
isInputBroken: (nodeID: string, handleID: string) => boolean;
getInputTypeMismatch: (
nodeID: string,
handleID: string,
) => string | undefined;
}; };
export const useNodeStore = create<NodeStore>((set, get) => ({ export const useNodeStore = create<NodeStore>((set, get) => ({
@@ -374,4 +413,99 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
useHistoryStore.getState().pushState(newState); useHistoryStore.getState().pushState(newState);
}, },
// Sub-agent resolution mode state
nodesInResolutionMode: new Set<string>(),
brokenEdgeIDs: new Map<string, 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);
const newBrokenEdgeIDs = new Map(state.brokenEdgeIDs);
if (inResolution) {
newNodesSet.add(nodeID);
if (resolutionData) {
newResolutionDataMap.set(nodeID, resolutionData);
}
} else {
newNodesSet.delete(nodeID);
newResolutionDataMap.delete(nodeID);
newBrokenEdgeIDs.delete(nodeID); // Clean up broken edges when exiting resolution mode
}
return {
nodesInResolutionMode: newNodesSet,
nodeResolutionData: newResolutionDataMap,
brokenEdgeIDs: newBrokenEdgeIDs,
};
});
},
isNodeInResolutionMode: (nodeID: string) => {
return get().nodesInResolutionMode.has(nodeID);
},
getNodeResolutionData: (nodeID: string) => {
return get().nodeResolutionData.get(nodeID);
},
setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => {
set((state) => {
const newMap = new Map(state.brokenEdgeIDs);
newMap.set(nodeID, new Set(edgeIDs));
return { brokenEdgeIDs: newMap };
});
},
removeBrokenEdgeID: (nodeID: string, edgeID: string) => {
set((state) => {
const newMap = new Map(state.brokenEdgeIDs);
const nodeSet = new Set(newMap.get(nodeID) || []);
nodeSet.delete(edgeID);
newMap.set(nodeID, nodeSet);
return { brokenEdgeIDs: newMap };
});
},
isEdgeBroken: (edgeID: string) => {
// Check across all nodes
const brokenEdgeIDs = get().brokenEdgeIDs;
for (const edgeSet of brokenEdgeIDs.values()) {
if (edgeSet.has(edgeID)) {
return true;
}
}
return false;
},
clearResolutionState: () => {
set({
nodesInResolutionMode: new Set<string>(),
brokenEdgeIDs: new Map<string, Set<string>>(),
nodeResolutionData: new Map<string, NodeResolutionData>(),
});
},
// Helper functions for input renderers
isInputBroken: (nodeID: string, handleID: string) => {
const resolutionData = get().nodeResolutionData.get(nodeID);
if (!resolutionData) return false;
return resolutionData.incompatibilities.missingInputs.includes(handleID);
},
getInputTypeMismatch: (nodeID: string, handleID: string) => {
const resolutionData = get().nodeResolutionData.get(nodeID);
if (!resolutionData) return undefined;
const mismatch = resolutionData.incompatibilities.inputTypeMismatches.find(
(m) => m.name === handleID,
);
return mismatch?.newType;
},
})); }));

View File

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

View File

@@ -29,7 +29,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
href: "/profile/dashboard", href: "/profile/dashboard",
icon: <StorefrontIcon className="size-5" />, icon: <StorefrontIcon className="size-5" />,
}, },
...(isPaymentEnabled || true ...(isPaymentEnabled
? [ ? [
{ {
text: "Billing", text: "Billing",

View File

@@ -32,7 +32,7 @@ export const extendedButtonVariants = cva(
), ),
}, },
size: { size: {
small: "px-3 py-2 text-sm gap-1.5 h-[2.25rem]", small: "px-3 py-2 text-sm gap-1.5 h-[2.25rem] min-w-[5.5rem]",
large: "px-4 py-3 text-sm gap-2 h-[3.25rem]", large: "px-4 py-3 text-sm gap-2 h-[3.25rem]",
icon: "p-3 !min-w-0", icon: "p-3 !min-w-0",
}, },

View File

@@ -30,8 +30,6 @@ export const FormRenderer = ({
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema); return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
}, [preprocessedSchema, uiSchema]); }, [preprocessedSchema, uiSchema]);
console.log("preprocessedSchema", preprocessedSchema);
return ( return (
<div className={"mb-6 mt-4"}> <div className={"mb-6 mt-4"}>
<Form <Form

View File

@@ -2,10 +2,12 @@ import { FieldProps, getUiOptions, getWidget } from "@rjsf/utils";
import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle"; import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import { useAnyOfField } from "./useAnyOfField"; import { useAnyOfField } from "./useAnyOfField";
import { getHandleId, updateUiOption } from "../../helpers"; import { cleanUpHandleId, getHandleId, updateUiOption } from "../../helpers";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { ANY_OF_FLAG } from "../../constants"; import { ANY_OF_FLAG } from "../../constants";
import { findCustomFieldId } from "../../registry"; import { findCustomFieldId } from "../../registry";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { cn } from "@/lib/utils";
export const AnyOfField = (props: FieldProps) => { export const AnyOfField = (props: FieldProps) => {
const { registry, schema } = props; const { registry, schema } = props;
@@ -21,6 +23,8 @@ export const AnyOfField = (props: FieldProps) => {
field_id, field_id,
} = useAnyOfField(props); } = useAnyOfField(props);
const isInputBroken = useNodeStore((state) => state.isInputBroken);
const parentCustomFieldId = findCustomFieldId(schema); const parentCustomFieldId = findCustomFieldId(schema);
if (parentCustomFieldId) { if (parentCustomFieldId) {
return null; return null;
@@ -43,6 +47,7 @@ export const AnyOfField = (props: FieldProps) => {
}); });
const isHandleConnected = isInputConnected(nodeId, handleId); const isHandleConnected = isInputConnected(nodeId, handleId);
const isAnyOfInputBroken = isInputBroken(nodeId, cleanUpHandleId(handleId));
// Now anyOf can render - custom fields if the option schema matches a custom field // Now anyOf can render - custom fields if the option schema matches a custom field
const optionCustomFieldId = optionSchema const optionCustomFieldId = optionSchema
@@ -78,7 +83,11 @@ export const AnyOfField = (props: FieldProps) => {
registry={registry} registry={registry}
placeholder={props.placeholder} placeholder={props.placeholder}
autocomplete={props.autocomplete} autocomplete={props.autocomplete}
className="-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium" className={cn(
"-ml-1 h-[22px] w-fit gap-1 px-1 pl-2 text-xs font-medium",
isAnyOfInputBroken &&
"border-red-500 bg-red-100 text-red-600 line-through",
)}
autofocus={props.autofocus} autofocus={props.autofocus}
label="" label=""
hideLabel={true} hideLabel={true}
@@ -93,7 +102,7 @@ export const AnyOfField = (props: FieldProps) => {
selector={selector} selector={selector}
uiSchema={updatedUiSchema} uiSchema={updatedUiSchema}
/> />
{!isHandleConnected && optionsSchemaField} {!isHandleConnected && !isAnyOfInputBroken && optionsSchemaField}
</div> </div>
); );
}; };

View File

@@ -13,6 +13,7 @@ import { Text } from "@/components/atoms/Text/Text";
import { isOptionalType } from "../../../utils/schema-utils"; import { isOptionalType } from "../../../utils/schema-utils";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers"; import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
interface customFieldProps extends FieldProps { interface customFieldProps extends FieldProps {
selector: JSX.Element; selector: JSX.Element;
@@ -51,6 +52,13 @@ export const AnyOfFieldTitle = (props: customFieldProps) => {
shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected; shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected;
const shoudlShowType = isHandleConnected || (isOptional && type); const shoudlShowType = isHandleConnected || (isOptional && type);
const isInputBroken = useNodeStore((state) =>
state.isInputBroken(nodeId, cleanUpHandleId(uiOptions.handleId)),
);
const inputMismatch = useNodeStore((state) =>
state.getInputTypeMismatch(nodeId, cleanUpHandleId(uiOptions.handleId)),
);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TitleFieldTemplate <TitleFieldTemplate
@@ -62,8 +70,16 @@ export const AnyOfFieldTitle = (props: customFieldProps) => {
uiSchema={uiSchema} uiSchema={uiSchema}
/> />
{shoudlShowType && ( {shoudlShowType && (
<Text variant="small" className={cn("text-zinc-700", colorClass)}> <Text
{isOptional ? `(${displayType})` : "(any)"} variant="small"
className={cn(
"text-zinc-700",
isInputBroken && "line-through",
colorClass,
inputMismatch && "rounded-md bg-red-100 px-1 !text-red-500",
)}
>
{isOptional ? `(${inputMismatch || displayType})` : "(any)"}
</Text> </Text>
)} )}
{shouldShowSelector && selector} {shouldShowSelector && selector}

View File

@@ -9,8 +9,9 @@ import { Text } from "@/components/atoms/Text/Text";
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers"; import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { isAnyOfSchema } from "../../utils/schema-utils"; import { isAnyOfSchema } from "../../utils/schema-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isArrayItem } from "../../helpers"; import { cleanUpHandleId, isArrayItem } from "../../helpers";
import { InputNodeHandle } from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle"; import { InputNodeHandle } from "@/app/(platform)/build/components/FlowEditor/handlers/NodeHandle";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
export default function TitleField(props: TitleFieldProps) { export default function TitleField(props: TitleFieldProps) {
const { id, title, required, schema, registry, uiSchema } = props; const { id, title, required, schema, registry, uiSchema } = props;
@@ -26,6 +27,11 @@ export default function TitleField(props: TitleFieldProps) {
const smallText = isArrayItemFlag || additional; const smallText = isArrayItemFlag || additional;
const showHandle = uiOptions.showHandles ?? showHandles; const showHandle = uiOptions.showHandles ?? showHandles;
const isInputBroken = useNodeStore((state) =>
state.isInputBroken(nodeId, cleanUpHandleId(uiOptions.handleId)),
);
return ( return (
<div className="flex items-center"> <div className="flex items-center">
{showHandle !== false && ( {showHandle !== false && (
@@ -34,7 +40,11 @@ export default function TitleField(props: TitleFieldProps) {
<Text <Text
variant={isArrayItemFlag ? "small" : "body"} variant={isArrayItemFlag ? "small" : "body"}
id={id} id={id}
className={cn("line-clamp-1", smallText && "text-sm text-zinc-700")} className={cn(
"line-clamp-1",
smallText && "text-sm text-zinc-700",
isInputBroken && "text-red-500 line-through",
)}
> >
{title} {title}
</Text> </Text>
@@ -44,7 +54,7 @@ export default function TitleField(props: TitleFieldProps) {
{!isAnyOf && ( {!isAnyOf && (
<Text <Text
variant="small" variant="small"
className={cn("ml-2", colorClass)} className={cn("ml-2", isInputBroken && "line-through", colorClass)}
id={description_id} id={description_id}
> >
({displayType}) ({displayType})

View File

@@ -30,6 +30,8 @@ export function updateUiOption<T extends Record<string, any>>(
} }
export const cleanUpHandleId = (handleId: string) => { export const cleanUpHandleId = (handleId: string) => {
if (!handleId) return "";
let newHandleId = handleId; let newHandleId = handleId;
if (handleId.includes(ANY_OF_FLAG)) { if (handleId.includes(ANY_OF_FLAG)) {
newHandleId = newHandleId.replace(ANY_OF_FLAG, ""); newHandleId = newHandleId.replace(ANY_OF_FLAG, "");

View File

@@ -233,13 +233,14 @@ export default function useAgentGraph(
title: `${block.name} ${node.id}`, title: `${block.name} ${node.id}`,
inputSchema: block.inputSchema, inputSchema: block.inputSchema,
outputSchema: block.outputSchema, outputSchema: block.outputSchema,
isOutputStatic: block.staticOutput,
hardcodedValues: node.input_default, hardcodedValues: node.input_default,
uiType: block.uiType, uiType: block.uiType,
metadata: metadata, metadata: metadata,
connections: graph.links connections: graph.links
.filter((l) => [l.source_id, l.sink_id].includes(node.id)) .filter((l) => [l.source_id, l.sink_id].includes(node.id))
.map((link) => ({ .map((link) => ({
edge_id: formatEdgeID(link), id: formatEdgeID(link),
source: link.source_id, source: link.source_id,
sourceHandle: link.source_name, sourceHandle: link.source_name,
target: link.sink_id, target: link.sink_id,

View File

@@ -245,8 +245,8 @@ export type BlockIONullSubSchema = BlockIOSubSchemaMeta & {
// At the time of writing, combined schemas only occur on the first nested level in a // At the time of writing, combined schemas only occur on the first nested level in a
// block schema. It is typed this way to make the use of these objects less tedious. // block schema. It is typed this way to make the use of these objects less tedious.
type BlockIOCombinedTypeSubSchema = BlockIOSubSchemaMeta & { type BlockIOCombinedTypeSubSchema = BlockIOSubSchemaMeta & {
type: never; type?: never;
const: never; const?: never;
} & ( } & (
| { | {
allOf: [BlockIOSimpleTypeSubSchema]; allOf: [BlockIOSimpleTypeSubSchema];
@@ -368,8 +368,8 @@ export type GraphMeta = {
recommended_schedule_cron: string | null; recommended_schedule_cron: string | null;
forked_from_id?: GraphID | null; forked_from_id?: GraphID | null;
forked_from_version?: number | null; forked_from_version?: number | null;
input_schema: GraphIOSchema; input_schema: GraphInputSchema;
output_schema: GraphIOSchema; output_schema: GraphOutputSchema;
credentials_input_schema: CredentialsInputSchema; credentials_input_schema: CredentialsInputSchema;
} & ( } & (
| { | {
@@ -385,19 +385,51 @@ export type GraphMeta = {
export type GraphID = Brand<string, "GraphID">; export type GraphID = Brand<string, "GraphID">;
/* Derived from backend/data/graph.py:Graph._generate_schema() */ /* Derived from backend/data/graph.py:Graph._generate_schema() */
export type GraphIOSchema = { export type GraphInputSchema = {
type: "object"; type: "object";
properties: Record<string, GraphIOSubSchema>; properties: Record<string, GraphInputSubSchema>;
required: (keyof BlockIORootSchema["properties"])[]; required: (keyof GraphInputSchema["properties"])[];
}; };
export type GraphIOSubSchema = Omit< export type GraphInputSubSchema = GraphOutputSubSchema &
BlockIOSubSchemaMeta, (
"placeholder" | "depends_on" | "hidden" | { type?: never; default: any | null } // AgentInputBlock (generic Any type)
> & { | { type: "string"; format: "short-text"; default: string | null } // AgentShortTextInputBlock
type: never; // bodge to avoid type checking hell; doesn't exist at runtime | { type: "string"; format: "long-text"; default: string | null } // AgentLongTextInputBlock
default?: string; | { type: "integer"; default: number | null } // AgentNumberInputBlock
| { type: "string"; format: "date"; default: string | null } // AgentDateInputBlock
| { type: "string"; format: "time"; default: string | null } // AgentTimeInputBlock
| { type: "string"; format: "file"; default: string | null } // AgentFileInputBlock
| { type: "string"; enum: string[]; default: string | null } // AgentDropdownInputBlock
| { type: "boolean"; default: boolean } // AgentToggleInputBlock
| {
// AgentTableInputBlock
type: "array";
format: "table";
items: {
type: "object";
properties: Record<string, { type: "string" }>;
};
default: Array<Record<string, string>> | null;
}
| {
// AgentGoogleDriveFileInputBlock
type: "object";
format: "google-drive-picker";
google_drive_picker_config?: GoogleDrivePickerConfig;
default: GoogleDriveFile | null;
}
);
export type GraphOutputSchema = {
type: "object";
properties: Record<string, GraphOutputSubSchema>;
required: (keyof GraphOutputSchema["properties"])[];
};
export type GraphOutputSubSchema = {
// TODO: typed outputs based on the incoming edges?
title: string;
description?: string;
advanced: boolean;
secret: boolean; secret: boolean;
metadata?: any;
}; };
export type CredentialsInputSchema = { export type CredentialsInputSchema = {
@@ -440,8 +472,8 @@ export type GraphUpdateable = Omit<
is_active?: boolean; is_active?: boolean;
nodes: NodeCreatable[]; nodes: NodeCreatable[];
links: LinkCreatable[]; links: LinkCreatable[];
input_schema?: GraphIOSchema; input_schema?: GraphInputSchema;
output_schema?: GraphIOSchema; output_schema?: GraphOutputSchema;
}; };
export type GraphCreatable = _GraphCreatableInner & { export type GraphCreatable = _GraphCreatableInner & {
@@ -497,8 +529,8 @@ export type LibraryAgent = {
name: string; name: string;
description: string; description: string;
instructions?: string | null; instructions?: string | null;
input_schema: GraphIOSchema; input_schema: GraphInputSchema;
output_schema: GraphIOSchema; output_schema: GraphOutputSchema;
credentials_input_schema: CredentialsInputSchema; credentials_input_schema: CredentialsInputSchema;
new_output: boolean; new_output: boolean;
can_access_graph: boolean; can_access_graph: boolean;

View File

@@ -6,7 +6,10 @@ import { NodeDimension } from "@/app/(platform)/build/components/legacy-builder/
import { import {
BlockIOObjectSubSchema, BlockIOObjectSubSchema,
BlockIORootSchema, BlockIORootSchema,
BlockIOSubSchema,
Category, Category,
GraphInputSubSchema,
GraphOutputSubSchema,
} from "@/lib/autogpt-server-api/types"; } from "@/lib/autogpt-server-api/types";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@@ -76,8 +79,8 @@ export function getTypeBgColor(type: string | null): string {
); );
} }
export function getTypeColor(type: string | null): string { export function getTypeColor(type: string | undefined): string {
if (type === null) return "#6b7280"; if (!type) return "#6b7280";
return ( return (
{ {
string: "#22c55e", string: "#22c55e",
@@ -88,11 +91,59 @@ export function getTypeColor(type: string | null): string {
array: "#6366f1", array: "#6366f1",
null: "#6b7280", null: "#6b7280",
any: "#6b7280", any: "#6b7280",
"": "#6b7280",
}[type] || "#6b7280" }[type] || "#6b7280"
); );
} }
/**
* Extracts the effective type from a JSON schema, handling anyOf/oneOf/allOf wrappers.
* Returns the first non-null type found in the schema structure.
*/
export function getEffectiveType(
schema:
| BlockIOSubSchema
| GraphInputSubSchema
| GraphOutputSubSchema
| null
| undefined,
): string | undefined {
if (!schema) return undefined;
// Direct type property
if ("type" in schema && schema.type) {
return String(schema.type);
}
// Handle allOf - typically a single-item wrapper
if (
"allOf" in schema &&
Array.isArray(schema.allOf) &&
schema.allOf.length > 0
) {
return getEffectiveType(schema.allOf[0]);
}
// Handle anyOf - e.g. [{ type: "string" }, { type: "null" }]
if ("anyOf" in schema && Array.isArray(schema.anyOf)) {
for (const item of schema.anyOf) {
if ("type" in item && item.type !== "null") {
return String(item.type);
}
}
}
// Handle oneOf
if ("oneOf" in schema && Array.isArray(schema.oneOf)) {
for (const item of schema.oneOf) {
if ("type" in item && item.type !== "null") {
return String(item.type);
}
}
}
return undefined;
}
export function beautifyString(name: string): string { export function beautifyString(name: string): string {
// Regular expression to identify places to split, considering acronyms // Regular expression to identify places to split, considering acronyms
const result = name const result = name