mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-14 17:47:57 -05:00
Compare commits
5 Commits
gitbook
...
pwuts/open
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0676ecbe53 | ||
|
|
a649d73060 | ||
|
|
3b09a94e3f | ||
|
|
61efee4139 | ||
|
|
e539280e98 |
@@ -35,11 +35,7 @@ from backend.data.model import (
|
||||
OAuth2Credentials,
|
||||
UserIntegrations,
|
||||
)
|
||||
from backend.data.onboarding import (
|
||||
OnboardingStep,
|
||||
complete_onboarding_step,
|
||||
increment_runs,
|
||||
)
|
||||
from backend.data.onboarding import OnboardingStep, complete_onboarding_step
|
||||
from backend.data.user import get_user_integrations
|
||||
from backend.executor.utils import add_graph_execution
|
||||
from backend.integrations.ayrshare import AyrshareClient, SocialPlatform
|
||||
@@ -378,7 +374,6 @@ async def webhook_ingress_generic(
|
||||
return
|
||||
|
||||
await complete_onboarding_step(user_id, OnboardingStep.TRIGGER_WEBHOOK)
|
||||
await increment_runs(user_id)
|
||||
|
||||
# Execute all triggers concurrently for better performance
|
||||
tasks = []
|
||||
|
||||
@@ -8,7 +8,6 @@ from backend.data.execution import GraphExecutionMeta
|
||||
from backend.data.graph import get_graph
|
||||
from backend.data.integrations import get_webhook
|
||||
from backend.data.model import CredentialsMetaInput
|
||||
from backend.data.onboarding import increment_runs
|
||||
from backend.executor.utils import add_graph_execution, make_node_credentials_input_map
|
||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||
from backend.integrations.webhooks import get_webhook_manager
|
||||
@@ -403,8 +402,6 @@ async def execute_preset(
|
||||
merged_node_input = preset.inputs | inputs
|
||||
merged_credential_inputs = preset.credentials | credential_inputs
|
||||
|
||||
await increment_runs(user_id)
|
||||
|
||||
return await add_graph_execution(
|
||||
user_id=user_id,
|
||||
graph_id=preset.graph_id,
|
||||
|
||||
@@ -64,7 +64,6 @@ from backend.data.onboarding import (
|
||||
complete_re_run_agent,
|
||||
get_recommended_agents,
|
||||
get_user_onboarding,
|
||||
increment_runs,
|
||||
onboarding_enabled,
|
||||
reset_user_onboarding,
|
||||
update_user_onboarding,
|
||||
@@ -975,7 +974,6 @@ async def execute_graph(
|
||||
# Record successful graph execution
|
||||
record_graph_execution(graph_id=graph_id, status="success", user_id=user_id)
|
||||
record_graph_operation(operation="execute", status="success")
|
||||
await increment_runs(user_id)
|
||||
await complete_re_run_agent(user_id, graph_id)
|
||||
if source == "library":
|
||||
await complete_onboarding_step(
|
||||
|
||||
@@ -18,6 +18,7 @@ from backend.data.model import (
|
||||
SchemaField,
|
||||
)
|
||||
from backend.integrations.providers import ProviderName
|
||||
from backend.util.request import DEFAULT_USER_AGENT
|
||||
|
||||
|
||||
class GetWikipediaSummaryBlock(Block, GetRequest):
|
||||
@@ -39,17 +40,27 @@ class GetWikipediaSummaryBlock(Block, GetRequest):
|
||||
output_schema=GetWikipediaSummaryBlock.Output,
|
||||
test_input={"topic": "Artificial Intelligence"},
|
||||
test_output=("summary", "summary content"),
|
||||
test_mock={"get_request": lambda url, json: {"extract": "summary content"}},
|
||||
test_mock={
|
||||
"get_request": lambda url, headers, json: {"extract": "summary content"}
|
||||
},
|
||||
)
|
||||
|
||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||
topic = input_data.topic
|
||||
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{topic}"
|
||||
# URL-encode the topic to handle spaces and special characters
|
||||
encoded_topic = quote(topic, safe="")
|
||||
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_topic}"
|
||||
|
||||
# Set headers per Wikimedia robot policy (https://w.wiki/4wJS)
|
||||
# - User-Agent: Required, must identify the bot
|
||||
# - Accept-Encoding: gzip recommended to reduce bandwidth
|
||||
headers = {
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
}
|
||||
|
||||
# Note: User-Agent is now automatically set by the request library
|
||||
# to comply with Wikimedia's robot policy (https://w.wiki/4wJS)
|
||||
try:
|
||||
response = await self.get_request(url, json=True)
|
||||
response = await self.get_request(url, headers=headers, json=True)
|
||||
if "extract" not in response:
|
||||
raise ValueError(f"Unable to parse Wikipedia response: {response}")
|
||||
yield "summary", response["extract"]
|
||||
|
||||
@@ -334,7 +334,7 @@ async def _get_user_timezone(user_id: str) -> str:
|
||||
return get_user_timezone_or_utc(user.timezone if user else None)
|
||||
|
||||
|
||||
async def increment_runs(user_id: str):
|
||||
async def increment_onboarding_runs(user_id: str):
|
||||
"""
|
||||
Increment a user's run counters and trigger any onboarding milestones.
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,7 @@ from backend.data.execution import (
|
||||
get_execution_kv_data,
|
||||
get_execution_outputs_by_node_exec_id,
|
||||
get_frequently_executed_graphs,
|
||||
get_graph_execution,
|
||||
get_graph_execution_meta,
|
||||
get_graph_executions,
|
||||
get_graph_executions_count,
|
||||
@@ -57,6 +58,7 @@ from backend.data.notifications import (
|
||||
get_user_notification_oldest_message_in_batch,
|
||||
remove_notifications_from_batch,
|
||||
)
|
||||
from backend.data.onboarding import increment_onboarding_runs
|
||||
from backend.data.user import (
|
||||
get_active_user_ids_in_timerange,
|
||||
get_user_by_id,
|
||||
@@ -140,6 +142,7 @@ class DatabaseManager(AppService):
|
||||
get_child_graph_executions = _(get_child_graph_executions)
|
||||
get_graph_executions = _(get_graph_executions)
|
||||
get_graph_executions_count = _(get_graph_executions_count)
|
||||
get_graph_execution = _(get_graph_execution)
|
||||
get_graph_execution_meta = _(get_graph_execution_meta)
|
||||
create_graph_execution = _(create_graph_execution)
|
||||
get_node_execution = _(get_node_execution)
|
||||
@@ -204,6 +207,9 @@ class DatabaseManager(AppService):
|
||||
add_store_agent_to_library = _(add_store_agent_to_library)
|
||||
validate_graph_execution_permissions = _(validate_graph_execution_permissions)
|
||||
|
||||
# Onboarding
|
||||
increment_onboarding_runs = _(increment_onboarding_runs)
|
||||
|
||||
# Store
|
||||
get_store_agents = _(get_store_agents)
|
||||
get_store_agent_details = _(get_store_agent_details)
|
||||
@@ -274,6 +280,7 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
||||
get_graph = d.get_graph
|
||||
get_graph_metadata = d.get_graph_metadata
|
||||
get_graph_settings = d.get_graph_settings
|
||||
get_graph_execution = d.get_graph_execution
|
||||
get_graph_execution_meta = d.get_graph_execution_meta
|
||||
get_node = d.get_node
|
||||
get_node_execution = d.get_node_execution
|
||||
@@ -318,6 +325,9 @@ class DatabaseManagerAsyncClient(AppServiceClient):
|
||||
add_store_agent_to_library = d.add_store_agent_to_library
|
||||
validate_graph_execution_permissions = d.validate_graph_execution_permissions
|
||||
|
||||
# Onboarding
|
||||
increment_onboarding_runs = d.increment_onboarding_runs
|
||||
|
||||
# Store
|
||||
get_store_agents = d.get_store_agents
|
||||
get_store_agent_details = d.get_store_agent_details
|
||||
|
||||
@@ -27,7 +27,6 @@ from backend.data.auth.oauth import cleanup_expired_oauth_tokens
|
||||
from backend.data.block import BlockInput
|
||||
from backend.data.execution import GraphExecutionWithNodes
|
||||
from backend.data.model import CredentialsMetaInput
|
||||
from backend.data.onboarding import increment_runs
|
||||
from backend.executor import utils as execution_utils
|
||||
from backend.monitoring import (
|
||||
NotificationJobArgs,
|
||||
@@ -156,7 +155,6 @@ async def _execute_graph(**kwargs):
|
||||
inputs=args.input_data,
|
||||
graph_credentials_inputs=args.input_credentials,
|
||||
)
|
||||
await increment_runs(args.user_id)
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
logger.info(
|
||||
f"Graph execution started with ID {graph_exec.id} for graph {args.graph_id} "
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel, JsonValue, ValidationError
|
||||
|
||||
from backend.data import execution as execution_db
|
||||
from backend.data import graph as graph_db
|
||||
from backend.data import onboarding as onboarding_db
|
||||
from backend.data import user as user_db
|
||||
from backend.data.block import (
|
||||
Block,
|
||||
@@ -31,7 +32,6 @@ from backend.data.execution import (
|
||||
GraphExecutionStats,
|
||||
GraphExecutionWithNodes,
|
||||
NodesInputMasks,
|
||||
get_graph_execution,
|
||||
)
|
||||
from backend.data.graph import GraphModel, Node
|
||||
from backend.data.model import USER_TIMEZONE_NOT_SET, CredentialsMetaInput
|
||||
@@ -809,13 +809,14 @@ async def add_graph_execution(
|
||||
edb = execution_db
|
||||
udb = user_db
|
||||
gdb = graph_db
|
||||
odb = onboarding_db
|
||||
else:
|
||||
edb = udb = gdb = get_database_manager_async_client()
|
||||
edb = udb = gdb = odb = get_database_manager_async_client()
|
||||
|
||||
# Get or create the graph execution
|
||||
if graph_exec_id:
|
||||
# Resume existing execution
|
||||
graph_exec = await get_graph_execution(
|
||||
graph_exec = await edb.get_graph_execution(
|
||||
user_id=user_id,
|
||||
execution_id=graph_exec_id,
|
||||
include_node_executions=True,
|
||||
@@ -891,6 +892,7 @@ async def add_graph_execution(
|
||||
)
|
||||
logger.info(f"Publishing execution {graph_exec.id} to execution queue")
|
||||
|
||||
# Publish to execution queue for executor to pick up
|
||||
exec_queue = await get_async_execution_queue()
|
||||
await exec_queue.publish_message(
|
||||
routing_key=GRAPH_EXECUTION_ROUTING_KEY,
|
||||
@@ -899,14 +901,12 @@ async def add_graph_execution(
|
||||
)
|
||||
logger.info(f"Published execution {graph_exec.id} to RabbitMQ queue")
|
||||
|
||||
# Update execution status to QUEUED
|
||||
graph_exec.status = ExecutionStatus.QUEUED
|
||||
await edb.update_graph_execution_stats(
|
||||
graph_exec_id=graph_exec.id,
|
||||
status=graph_exec.status,
|
||||
)
|
||||
await get_async_execution_event_bus().publish(graph_exec)
|
||||
|
||||
return graph_exec
|
||||
except BaseException as e:
|
||||
err = str(e) or type(e).__name__
|
||||
if not graph_exec:
|
||||
@@ -927,6 +927,24 @@ async def add_graph_execution(
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
await get_async_execution_event_bus().publish(graph_exec)
|
||||
logger.info(f"Published update for execution #{graph_exec.id} to event bus")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to publish execution event for graph exec #{graph_exec.id}: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
await odb.increment_onboarding_runs(user_id)
|
||||
logger.info(
|
||||
f"Incremented user #{user_id} onboarding runs for exec #{graph_exec.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to increment onboarding runs for user #{user_id}: {e}")
|
||||
|
||||
return graph_exec
|
||||
|
||||
|
||||
# ============ Execution Output Helpers ============ #
|
||||
|
||||
|
||||
@@ -81,18 +81,16 @@ export const RunInputDialog = ({
|
||||
Inputs
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/def
|
||||
import {
|
||||
useGetV1GetExecutionDetails,
|
||||
useGetV1GetSpecificGraph,
|
||||
useGetV1ListUserGraphs,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
|
||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||
@@ -17,6 +18,7 @@ import { useReactFlow } from "@xyflow/react";
|
||||
import { useControlPanelStore } from "../../../stores/controlPanelStore";
|
||||
import { useHistoryStore } from "../../../stores/historyStore";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
|
||||
export const useFlow = () => {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
@@ -36,6 +38,9 @@ export const useFlow = () => {
|
||||
const setGraphExecutionStatus = useGraphStore(
|
||||
useShallow((state) => state.setGraphExecutionStatus),
|
||||
);
|
||||
const setAvailableSubGraphs = useGraphStore(
|
||||
useShallow((state) => state.setAvailableSubGraphs),
|
||||
);
|
||||
const updateEdgeBeads = useEdgeStore(
|
||||
useShallow((state) => state.updateEdgeBeads),
|
||||
);
|
||||
@@ -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(
|
||||
flowID ?? "",
|
||||
flowVersion !== null ? { version: flowVersion } : {},
|
||||
@@ -116,10 +126,18 @@ export const useFlow = () => {
|
||||
}
|
||||
}, [graph]);
|
||||
|
||||
// Update available sub-graphs in store for sub-agent update detection
|
||||
useEffect(() => {
|
||||
if (availableGraphs) {
|
||||
setAvailableSubGraphs(availableGraphs);
|
||||
}
|
||||
}, [availableGraphs, setAvailableSubGraphs]);
|
||||
|
||||
// adding nodes
|
||||
useEffect(() => {
|
||||
if (customNodes.length > 0) {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
useNodeStore.getState().clearResolutionState();
|
||||
addNodes(customNodes);
|
||||
|
||||
// Sync hardcoded values with handle IDs.
|
||||
@@ -203,6 +221,7 @@ export const useFlow = () => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useNodeStore.getState().setNodes([]);
|
||||
useNodeStore.getState().clearResolutionState();
|
||||
useEdgeStore.getState().setEdges([]);
|
||||
useGraphStore.getState().reset();
|
||||
useEdgeStore.getState().resetEdgeBeads();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getBezierPath,
|
||||
} from "@xyflow/react";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
|
||||
@@ -35,6 +36,8 @@ const CustomEdge = ({
|
||||
selected,
|
||||
}: EdgeProps<CustomEdge>) => {
|
||||
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 [edgePath, labelX, labelY] = getBezierPath({
|
||||
@@ -50,6 +53,12 @@ const CustomEdge = ({
|
||||
const beadUp = data?.beadUp ?? 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 (
|
||||
<>
|
||||
<BaseEdge
|
||||
@@ -57,9 +66,11 @@ const CustomEdge = ({
|
||||
markerEnd={markerEnd}
|
||||
className={cn(
|
||||
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
|
||||
selected
|
||||
? "stroke-zinc-800"
|
||||
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
||||
isBroken
|
||||
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
|
||||
: selected
|
||||
? "stroke-zinc-800"
|
||||
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
||||
)}
|
||||
/>
|
||||
<JSBeads
|
||||
@@ -70,12 +81,16 @@ const CustomEdge = ({
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<Button
|
||||
onClick={() => removeConnection(id)}
|
||||
onClick={handleRemoveEdge}
|
||||
className={cn(
|
||||
"absolute h-fit min-w-0 p-1 transition-opacity",
|
||||
isHovered ? "opacity-100" : "opacity-0",
|
||||
isBroken
|
||||
? "bg-red-500 opacity-100 hover:bg-red-600"
|
||||
: isHovered
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
variant="secondary"
|
||||
variant={isBroken ? "primary" : "secondary"}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: "all",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react";
|
||||
import { useEdgeStore } from "../../../stores/edgeStore";
|
||||
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
|
||||
const InputNodeHandle = ({
|
||||
handleId,
|
||||
@@ -15,6 +16,9 @@ const InputNodeHandle = ({
|
||||
const isInputConnected = useEdgeStore((state) =>
|
||||
state.isInputConnected(nodeId ?? "", cleanedHandleId),
|
||||
);
|
||||
const isInputBroken = useNodeStore((state) =>
|
||||
state.isInputBroken(nodeId, cleanedHandleId),
|
||||
);
|
||||
|
||||
return (
|
||||
<Handle
|
||||
@@ -27,7 +31,10 @@ const InputNodeHandle = ({
|
||||
<CircleIcon
|
||||
size={16}
|
||||
weight={isInputConnected ? "fill" : "duotone"}
|
||||
className={"text-gray-400 opacity-100"}
|
||||
className={cn(
|
||||
"text-gray-400 opacity-100",
|
||||
isInputBroken && "text-red-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
@@ -38,14 +45,17 @@ const OutputNodeHandle = ({
|
||||
field_name,
|
||||
nodeId,
|
||||
hexColor,
|
||||
isBroken,
|
||||
}: {
|
||||
field_name: string;
|
||||
nodeId: string;
|
||||
hexColor: string;
|
||||
isBroken: boolean;
|
||||
}) => {
|
||||
const isOutputConnected = useEdgeStore((state) =>
|
||||
state.isOutputConnected(nodeId, field_name),
|
||||
);
|
||||
|
||||
return (
|
||||
<Handle
|
||||
type={"source"}
|
||||
@@ -58,7 +68,10 @@ const OutputNodeHandle = ({
|
||||
size={16}
|
||||
weight={"duotone"}
|
||||
color={isOutputConnected ? hexColor : "gray"}
|
||||
className={cn("text-gray-400 opacity-100")}
|
||||
className={cn(
|
||||
"text-gray-400 opacity-100",
|
||||
isBroken && "text-red-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
|
||||
import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
|
||||
import { StickyNoteBlock } from "./components/StickyNoteBlock";
|
||||
import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
|
||||
import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
|
||||
import { useCustomNode } from "./useCustomNode";
|
||||
|
||||
export type CustomNodeData = {
|
||||
hardcodedValues: {
|
||||
@@ -45,6 +47,10 @@ export type CustomNode = XYNode<CustomNodeData, "custom">;
|
||||
|
||||
export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
({ data, id: nodeId, selected }) => {
|
||||
const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
|
||||
|
||||
const isAgent = data.uiType === BlockUIType.AGENT;
|
||||
|
||||
if (data.uiType === BlockUIType.NOTE) {
|
||||
return (
|
||||
<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 inputSchema =
|
||||
data.uiType === BlockUIType.AGENT
|
||||
? (data.hardcodedValues.input_schema ?? {})
|
||||
: data.inputSchema;
|
||||
|
||||
const outputSchema =
|
||||
data.uiType === BlockUIType.AGENT
|
||||
? (data.hardcodedValues.output_schema ?? {})
|
||||
: data.outputSchema;
|
||||
|
||||
const hasConfigErrors =
|
||||
data.errors &&
|
||||
Object.values(data.errors).some(
|
||||
@@ -87,12 +83,11 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
|
||||
|
||||
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 = (
|
||||
<NodeContainer selected={selected} nodeId={nodeId} hasErrors={hasErrors}>
|
||||
<div className="rounded-xlarge bg-white">
|
||||
<NodeHeader data={data} nodeId={nodeId} />
|
||||
{isAgent && <SubAgentUpdateFeature nodeID={nodeId} nodeData={data} />}
|
||||
{isWebhook && <WebhookDisclaimer nodeId={nodeId} />}
|
||||
{isAyrshare && <AyrshareConnectButton />}
|
||||
<FormCreator
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
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> = {
|
||||
INCOMPLETE: "ring-slate-300 bg-slate-300",
|
||||
@@ -9,3 +11,48 @@ export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
|
||||
TERMINATED: "ring-orange-300 bg-orange-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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -5,20 +5,16 @@ import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { BlockUIType } from "../../types";
|
||||
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
|
||||
|
||||
export const FormCreator = React.memo(
|
||||
({
|
||||
jsonSchema,
|
||||
nodeId,
|
||||
uiType,
|
||||
showHandles = true,
|
||||
className,
|
||||
}: {
|
||||
jsonSchema: RJSFSchema;
|
||||
nodeId: string;
|
||||
uiType: BlockUIType;
|
||||
showHandles?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
interface FormCreatorProps {
|
||||
jsonSchema: RJSFSchema;
|
||||
nodeId: string;
|
||||
uiType: BlockUIType;
|
||||
showHandles?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FormCreator: React.FC<FormCreatorProps> = React.memo(
|
||||
({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
|
||||
const updateNodeData = useNodeStore((state) => state.updateNodeData);
|
||||
|
||||
const getHardCodedValues = useNodeStore(
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { getTypeDisplayInfo } from "./helpers";
|
||||
import { BlockUIType } from "../../types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBrokenOutputs } from "./useBrokenOutputs";
|
||||
|
||||
export const OutputHandler = ({
|
||||
outputSchema,
|
||||
@@ -27,6 +29,9 @@ export const OutputHandler = ({
|
||||
const { isOutputConnected } = useEdgeStore();
|
||||
const properties = outputSchema?.properties || {};
|
||||
const [isOutputVisible, setIsOutputVisible] = useState(true);
|
||||
const brokenOutputs = useBrokenOutputs(nodeId);
|
||||
|
||||
console.log("brokenOutputs", brokenOutputs);
|
||||
|
||||
const showHandles = uiType !== BlockUIType.OUTPUT;
|
||||
|
||||
@@ -44,6 +49,7 @@ export const OutputHandler = ({
|
||||
const shouldShow = isConnected || isOutputVisible;
|
||||
const { displayType, colorClass, hexColor } =
|
||||
getTypeDisplayInfo(fieldSchema);
|
||||
const isBroken = brokenOutputs.has(fullKey);
|
||||
|
||||
return shouldShow ? (
|
||||
<div key={fullKey} className="flex flex-col items-end gap-2">
|
||||
@@ -64,15 +70,29 @@ export const OutputHandler = ({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Text variant="body" className="text-slate-700">
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"text-slate-700",
|
||||
isBroken && "text-red-500 line-through",
|
||||
)}
|
||||
>
|
||||
{fieldTitle}
|
||||
</Text>
|
||||
<Text variant="small" as="span" className={colorClass}>
|
||||
<Text
|
||||
variant="small"
|
||||
as="span"
|
||||
className={cn(
|
||||
colorClass,
|
||||
isBroken && "!text-red-500 line-through",
|
||||
)}
|
||||
>
|
||||
({displayType})
|
||||
</Text>
|
||||
|
||||
{showHandles && (
|
||||
<OutputNodeHandle
|
||||
isBroken={isBroken}
|
||||
field_name={fullKey}
|
||||
nodeId={nodeId}
|
||||
hexColor={hexColor}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const RightSidebar = () => {
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200">
|
||||
Flow Debug Panel
|
||||
Graph Debug Panel
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export const RightSidebar = () => {
|
||||
{l.source_id}[{l.source_name}] → {l.sink_id}[{l.sink_name}]
|
||||
</div>
|
||||
<div className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
edge_id: {l.id}
|
||||
edge.id: {l.id}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} 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 { IconToyBrick } from "@/components/__legacy__/ui/icons";
|
||||
import { getPrimaryCategoryColor } from "@/lib/utils";
|
||||
@@ -24,8 +31,10 @@ import {
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import jaro from "jaro-winkler";
|
||||
|
||||
type _Block = Block & {
|
||||
type _Block = Omit<Block, "inputSchema" | "outputSchema"> & {
|
||||
uiKey?: string;
|
||||
inputSchema: BlockIORootSchema | GraphInputSchema;
|
||||
outputSchema: BlockIORootSchema | GraphOutputSchema;
|
||||
hardcodedValues?: Record<string, any>;
|
||||
_cached?: {
|
||||
blockName: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -13,6 +13,7 @@ interface Props {
|
||||
isRunning: boolean;
|
||||
isDisabled: boolean;
|
||||
className?: string;
|
||||
resolutionModeActive?: boolean;
|
||||
}
|
||||
|
||||
export const BuildActionBar: React.FC<Props> = ({
|
||||
@@ -23,9 +24,30 @@ export const BuildActionBar: React.FC<Props> = ({
|
||||
isRunning,
|
||||
isDisabled,
|
||||
className,
|
||||
resolutionModeActive = false,
|
||||
}) => {
|
||||
const buttonClasses =
|
||||
"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 (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -60,10 +60,16 @@ export function CustomEdge({
|
||||
targetY - 5,
|
||||
);
|
||||
const { deleteElements } = useReactFlow<Node, CustomEdge>();
|
||||
const { visualizeBeads } = useContext(BuilderContext) ?? {
|
||||
const builderContext = useContext(BuilderContext);
|
||||
const { visualizeBeads } = builderContext ?? {
|
||||
visualizeBeads: "no",
|
||||
};
|
||||
|
||||
// Check if this edge is broken (during resolution mode)
|
||||
const isBroken =
|
||||
builderContext?.resolutionMode?.active &&
|
||||
builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
|
||||
|
||||
const onEdgeRemoveClick = () => {
|
||||
deleteElements({ edges: [{ id }] });
|
||||
};
|
||||
@@ -171,12 +177,27 @@ export function CustomEdge({
|
||||
|
||||
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 (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={svgPath}
|
||||
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
|
||||
d={svgPath}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
BlockIOSubSchema,
|
||||
BlockUIType,
|
||||
Category,
|
||||
GraphInputSchema,
|
||||
GraphOutputSchema,
|
||||
NodeExecutionResult,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import {
|
||||
@@ -62,14 +64,21 @@ import { NodeGenericInputField, NodeTextBoxInput } from "../NodeInputs";
|
||||
import NodeOutputs from "../NodeOutputs";
|
||||
import OutputModalComponent from "../OutputModalComponent";
|
||||
import "./customnode.css";
|
||||
import { SubAgentUpdateBar } from "./SubAgentUpdateBar";
|
||||
import { IncompatibilityDialog } from "./IncompatibilityDialog";
|
||||
import {
|
||||
useSubAgentUpdate,
|
||||
createUpdatedAgentNodeInputs,
|
||||
getBrokenEdgeIDs,
|
||||
} from "../../../hooks/useSubAgentUpdate";
|
||||
|
||||
export type ConnectionData = Array<{
|
||||
edge_id: string;
|
||||
export type ConnectedEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
sourceHandle: string;
|
||||
target: string;
|
||||
targetHandle: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CustomNodeData = {
|
||||
blockType: string;
|
||||
@@ -80,7 +89,7 @@ export type CustomNodeData = {
|
||||
inputSchema: BlockIORootSchema;
|
||||
outputSchema: BlockIORootSchema;
|
||||
hardcodedValues: { [key: string]: any };
|
||||
connections: ConnectionData;
|
||||
connections: ConnectedEdge[];
|
||||
isOutputOpen: boolean;
|
||||
status?: NodeExecutionResult["status"];
|
||||
/** executionResults contains outputs across multiple executions
|
||||
@@ -127,20 +136,199 @@ export const CustomNode = React.memo(
|
||||
|
||||
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) {
|
||||
throw new Error(
|
||||
"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(() => {
|
||||
if (data.executionResults || data.status) {
|
||||
@@ -156,13 +344,6 @@ export const CustomNode = React.memo(
|
||||
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
|
||||
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
|
||||
|
||||
const setHardcodedValues = useCallback(
|
||||
(values: any) => {
|
||||
updateNodeData(id, { hardcodedValues: values });
|
||||
},
|
||||
[id, updateNodeData],
|
||||
);
|
||||
|
||||
const handleTitleEdit = useCallback(() => {
|
||||
setIsEditingTitle(true);
|
||||
setTimeout(() => {
|
||||
@@ -255,6 +436,7 @@ export const CustomNode = React.memo(
|
||||
isConnected={isOutputHandleConnected(propKey)}
|
||||
schema={fieldSchema}
|
||||
side="right"
|
||||
isBroken={isOutputHandleBroken(propKey)}
|
||||
/>
|
||||
{"properties" in fieldSchema &&
|
||||
renderHandles(
|
||||
@@ -385,6 +567,7 @@ export const CustomNode = React.memo(
|
||||
isRequired={isRequired}
|
||||
schema={propSchema}
|
||||
side="left"
|
||||
isBroken={isInputHandleBroken(propKey)}
|
||||
/>
|
||||
) : (
|
||||
propKey !== "credentials" &&
|
||||
@@ -873,6 +1056,22 @@ export const CustomNode = React.memo(
|
||||
<ContextMenuContent />
|
||||
</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 */}
|
||||
<div className="mx-5 my-6 rounded-b-xl">
|
||||
{/* Input Handles */}
|
||||
@@ -1044,9 +1243,24 @@ export const CustomNode = React.memo(
|
||||
);
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -26,15 +26,17 @@ import {
|
||||
applyNodeChanges,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { CustomNode } from "../CustomNode/CustomNode";
|
||||
import { ConnectedEdge, CustomNode } from "../CustomNode/CustomNode";
|
||||
import "./flow.css";
|
||||
import {
|
||||
BlockUIType,
|
||||
formatEdgeID,
|
||||
GraphExecutionID,
|
||||
GraphID,
|
||||
GraphMeta,
|
||||
LibraryAgent,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
|
||||
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
|
||||
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 = {
|
||||
libraryAgent: LibraryAgent | null;
|
||||
visualizeBeads: "no" | "static" | "animate";
|
||||
setIsAnyModalOpen: (isOpen: boolean) => void;
|
||||
getNextNodeId: () => string;
|
||||
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 = {
|
||||
@@ -172,6 +192,92 @@ const FlowEditor: React.FC<{
|
||||
// It stores the dimension of all nodes with position as well
|
||||
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
|
||||
useEffect(() => {
|
||||
document.title = savedAgent
|
||||
@@ -431,17 +537,19 @@ const FlowEditor: React.FC<{
|
||||
...node.data.connections.filter(
|
||||
(conn) =>
|
||||
!removedEdges.some(
|
||||
(removedEdge) => removedEdge.id === conn.edge_id,
|
||||
(removedEdge) => removedEdge.id === conn.id,
|
||||
),
|
||||
),
|
||||
// Add node connections for added edges
|
||||
...addedEdges.map((addedEdge) => ({
|
||||
edge_id: addedEdge.item.id,
|
||||
source: addedEdge.item.source,
|
||||
target: addedEdge.item.target,
|
||||
sourceHandle: addedEdge.item.sourceHandle!,
|
||||
targetHandle: addedEdge.item.targetHandle!,
|
||||
})),
|
||||
...addedEdges.map(
|
||||
(addedEdge): ConnectedEdge => ({
|
||||
id: addedEdge.item.id,
|
||||
source: addedEdge.item.source,
|
||||
target: addedEdge.item.target,
|
||||
sourceHandle: addedEdge.item.sourceHandle!,
|
||||
targetHandle: addedEdge.item.targetHandle!,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
}));
|
||||
@@ -467,13 +575,15 @@ const FlowEditor: React.FC<{
|
||||
data: {
|
||||
...node.data,
|
||||
connections: [
|
||||
...replaceEdges.map((replaceEdge) => ({
|
||||
edge_id: replaceEdge.item.id,
|
||||
source: replaceEdge.item.source,
|
||||
target: replaceEdge.item.target,
|
||||
sourceHandle: replaceEdge.item.sourceHandle!,
|
||||
targetHandle: replaceEdge.item.targetHandle!,
|
||||
})),
|
||||
...replaceEdges.map(
|
||||
(replaceEdge): ConnectedEdge => ({
|
||||
id: replaceEdge.item.id,
|
||||
source: replaceEdge.item.source,
|
||||
target: replaceEdge.item.target,
|
||||
sourceHandle: replaceEdge.item.sourceHandle!,
|
||||
targetHandle: replaceEdge.item.targetHandle!,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
})),
|
||||
@@ -890,8 +1000,23 @@ const FlowEditor: React.FC<{
|
||||
setIsAnyModalOpen,
|
||||
getNextNodeId,
|
||||
getNodeTitle,
|
||||
availableFlows,
|
||||
resolutionMode,
|
||||
enterResolutionMode,
|
||||
exitResolutionMode,
|
||||
applyPendingUpdate,
|
||||
}),
|
||||
[libraryAgent, visualizeBeads, getNextNodeId, getNodeTitle],
|
||||
[
|
||||
libraryAgent,
|
||||
visualizeBeads,
|
||||
getNextNodeId,
|
||||
getNodeTitle,
|
||||
availableFlows,
|
||||
resolutionMode,
|
||||
enterResolutionMode,
|
||||
applyPendingUpdate,
|
||||
exitResolutionMode,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -991,6 +1116,7 @@ const FlowEditor: React.FC<{
|
||||
onClickScheduleButton={handleScheduleButton}
|
||||
isDisabled={!savedAgent}
|
||||
isRunning={isRunning}
|
||||
resolutionModeActive={resolutionMode.active}
|
||||
/>
|
||||
) : (
|
||||
<Alert className="absolute bottom-4 left-1/2 z-20 w-auto -translate-x-1/2 select-none">
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils";
|
||||
import {
|
||||
cn,
|
||||
beautifyString,
|
||||
getTypeBgColor,
|
||||
getTypeTextColor,
|
||||
getEffectiveType,
|
||||
} from "@/lib/utils";
|
||||
import { FC, memo, useCallback } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||
@@ -13,6 +18,7 @@ type HandleProps = {
|
||||
side: "left" | "right";
|
||||
title?: string;
|
||||
className?: string;
|
||||
isBroken?: boolean;
|
||||
};
|
||||
|
||||
// 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.
|
||||
const Dot: FC<{ isConnected: boolean; type?: string }> = memo(
|
||||
({ isConnected, type }) => {
|
||||
const color = isConnected
|
||||
? getTypeBgColor(type || "any")
|
||||
: "border-gray-300 dark:border-gray-600";
|
||||
const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> =
|
||||
memo(({ isConnected, type, isBroken }) => {
|
||||
const color = isBroken
|
||||
? "border-red-500 bg-red-100 dark:bg-red-900/30"
|
||||
: isConnected
|
||||
? getTypeBgColor(type || "any")
|
||||
: "border-gray-300 dark:border-gray-600";
|
||||
return (
|
||||
<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";
|
||||
|
||||
const NodeHandle: FC<HandleProps> = ({
|
||||
@@ -49,24 +60,34 @@ const NodeHandle: FC<HandleProps> = ({
|
||||
side,
|
||||
title,
|
||||
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"
|
||||
}`;
|
||||
|
||||
const label = (
|
||||
<div className="flex flex-grow flex-row">
|
||||
<div className={cn("flex flex-grow flex-row", isBroken && "opacity-50")}>
|
||||
<span
|
||||
className={cn(
|
||||
"data-sentry-unmask text-m green flex items-end pr-2 text-gray-900 dark:text-gray-100",
|
||||
className,
|
||||
isBroken && "text-red-500 line-through",
|
||||
)}
|
||||
>
|
||||
{title || schema.title || beautifyString(keyName.toLowerCase())}
|
||||
{isRequired ? "*" : ""}
|
||||
</span>
|
||||
<span className={`${typeClass} data-sentry-unmask flex items-end`}>
|
||||
({TYPE_NAME[schema.type as keyof typeof TYPE_NAME] || "any"})
|
||||
<span
|
||||
className={cn(
|
||||
`${typeClass} data-sentry-unmask flex items-end`,
|
||||
isBroken && "text-red-400",
|
||||
)}
|
||||
>
|
||||
({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -84,7 +105,7 @@ const NodeHandle: FC<HandleProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={keyName}
|
||||
className="handle-container"
|
||||
className={cn("handle-container", isBroken && "pointer-events-none")}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Handle
|
||||
@@ -92,10 +113,15 @@ const NodeHandle: FC<HandleProps> = ({
|
||||
data-testid={`input-handle-${keyName}`}
|
||||
position={Position.Left}
|
||||
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">
|
||||
<Dot isConnected={isConnected} type={schema.type} />
|
||||
<Dot
|
||||
isConnected={isConnected}
|
||||
type={effectiveType}
|
||||
isBroken={isBroken}
|
||||
/>
|
||||
{label}
|
||||
</div>
|
||||
</Handle>
|
||||
@@ -106,7 +132,10 @@ const NodeHandle: FC<HandleProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={keyName}
|
||||
className="handle-container justify-end"
|
||||
className={cn(
|
||||
"handle-container justify-end",
|
||||
isBroken && "pointer-events-none",
|
||||
)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Handle
|
||||
@@ -114,11 +143,16 @@ const NodeHandle: FC<HandleProps> = ({
|
||||
data-testid={`output-handle-${keyName}`}
|
||||
position={Position.Right}
|
||||
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">
|
||||
{label}
|
||||
<Dot isConnected={isConnected} type={schema.type} />
|
||||
<Dot
|
||||
isConnected={isConnected}
|
||||
type={effectiveType}
|
||||
isBroken={isBroken}
|
||||
/>
|
||||
</div>
|
||||
</Handle>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
ConnectionData,
|
||||
ConnectedEdge,
|
||||
CustomNodeData,
|
||||
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
|
||||
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
|
||||
@@ -65,7 +65,7 @@ type NodeObjectInputTreeProps = {
|
||||
selfKey?: string;
|
||||
schema: BlockIORootSchema | BlockIOObjectSubSchema;
|
||||
object?: { [key: string]: any };
|
||||
connections: ConnectionData;
|
||||
connections: ConnectedEdge[];
|
||||
handleInputClick: (key: string) => void;
|
||||
handleInputChange: (key: string, value: any) => void;
|
||||
errors: { [key: string]: string | undefined };
|
||||
@@ -585,7 +585,7 @@ const NodeOneOfDiscriminatorField: FC<{
|
||||
currentValue?: any;
|
||||
defaultValue?: any;
|
||||
errors: { [key: string]: string | undefined };
|
||||
connections: ConnectionData;
|
||||
connections: ConnectedEdge[];
|
||||
handleInputChange: (key: string, value: any) => void;
|
||||
handleInputClick: (key: string) => void;
|
||||
className?: string;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { FC, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
|
||||
import {
|
||||
import type {
|
||||
BlockIOTableSubSchema,
|
||||
TableCellValue,
|
||||
TableRow,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
import type { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PlusIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "../../../../../components/atoms/Button/Button";
|
||||
import { Input } from "../../../../../components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
interface NodeTableInputProps {
|
||||
/** Unique identifier for the node in the builder graph */
|
||||
@@ -25,13 +26,7 @@ interface NodeTableInputProps {
|
||||
/** Validation errors mapped by field key */
|
||||
errors: { [key: string]: string | undefined };
|
||||
/** Graph connections between nodes in the builder */
|
||||
connections: {
|
||||
edge_id: string;
|
||||
source: string;
|
||||
sourceHandle: string;
|
||||
target: string;
|
||||
targetHandle: string;
|
||||
}[];
|
||||
connections: ConnectedEdge[];
|
||||
/** Callback when table data changes */
|
||||
handleInputChange: (key: string, value: TableRow[]) => void;
|
||||
/** Callback when input field is clicked (for builder selection) */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { Node, Edge, useReactFlow } from "@xyflow/react";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { ConnectedEdge } from "./CustomNode/CustomNode";
|
||||
|
||||
interface CopyableData {
|
||||
nodes: Node[];
|
||||
@@ -111,13 +112,15 @@ export function useCopyPaste(getNextNodeId: () => string) {
|
||||
(edge: Edge) =>
|
||||
edge.source === node.id || edge.target === node.id,
|
||||
)
|
||||
.map((edge: Edge) => ({
|
||||
edge_id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
}));
|
||||
.map(
|
||||
(edge: Edge): ConnectedEdge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle!,
|
||||
targetHandle: edge.targetHandle!,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...node,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useSubAgentUpdate } from "./useSubAgentUpdate";
|
||||
export { createUpdatedAgentNodeInputs, getBrokenEdgeIDs } from "./helpers";
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
|
||||
|
||||
interface GraphStore {
|
||||
graphExecutionStatus: AgentExecutionStatus | undefined;
|
||||
@@ -17,6 +18,10 @@ interface GraphStore {
|
||||
outputSchema: Record<string, any> | null,
|
||||
) => void;
|
||||
|
||||
// Available graphs; used for sub-graph updates
|
||||
availableSubGraphs: GraphMeta[];
|
||||
setAvailableSubGraphs: (graphs: GraphMeta[]) => void;
|
||||
|
||||
hasInputs: () => boolean;
|
||||
hasCredentials: () => boolean;
|
||||
hasOutputs: () => boolean;
|
||||
@@ -29,6 +34,7 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
|
||||
inputSchema: null,
|
||||
credentialsInputSchema: null,
|
||||
outputSchema: null,
|
||||
availableSubGraphs: [],
|
||||
|
||||
setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => {
|
||||
set({
|
||||
@@ -46,6 +52,8 @@ export const useGraphStore = create<GraphStore>((set, get) => ({
|
||||
setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) =>
|
||||
set({ inputSchema, credentialsInputSchema, outputSchema }),
|
||||
|
||||
setAvailableSubGraphs: (graphs) => set({ availableSubGraphs: graphs }),
|
||||
|
||||
hasOutputs: () => {
|
||||
const { outputSchema } = get();
|
||||
return Object.keys(outputSchema?.properties ?? {}).length > 0;
|
||||
|
||||
@@ -17,6 +17,25 @@ import {
|
||||
ensurePathExists,
|
||||
parseHandleIdToPath,
|
||||
} 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
|
||||
// Prevents spamming history with small movements when clicking on inputs inside blocks
|
||||
@@ -65,12 +84,32 @@ type NodeStore = {
|
||||
backendId: string,
|
||||
errors: { [key: string]: string },
|
||||
) => void;
|
||||
clearAllNodeErrors: () => void; // Add this
|
||||
|
||||
syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
|
||||
|
||||
// Credentials optional helpers
|
||||
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) => ({
|
||||
@@ -374,4 +413,99 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
|
||||
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;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -18,7 +18,7 @@ function ErrorPageContent() {
|
||||
) {
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
window.location.href = "/marketplace";
|
||||
window.document.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
href: "/profile/dashboard",
|
||||
icon: <StorefrontIcon className="size-5" />,
|
||||
},
|
||||
...(isPaymentEnabled || true
|
||||
...(isPaymentEnabled
|
||||
? [
|
||||
{
|
||||
text: "Billing",
|
||||
|
||||
@@ -32,7 +32,7 @@ export const extendedButtonVariants = cva(
|
||||
),
|
||||
},
|
||||
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]",
|
||||
icon: "p-3 !min-w-0",
|
||||
},
|
||||
|
||||
@@ -30,8 +30,6 @@ export const FormRenderer = ({
|
||||
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
|
||||
}, [preprocessedSchema, uiSchema]);
|
||||
|
||||
console.log("preprocessedSchema", preprocessedSchema);
|
||||
|
||||
return (
|
||||
<div className={"mb-6 mt-4"}>
|
||||
<Form
|
||||
|
||||
@@ -2,10 +2,12 @@ import { FieldProps, getUiOptions, getWidget } from "@rjsf/utils";
|
||||
import { AnyOfFieldTitle } from "./components/AnyOfFieldTitle";
|
||||
import { isEmpty } from "lodash";
|
||||
import { useAnyOfField } from "./useAnyOfField";
|
||||
import { getHandleId, updateUiOption } from "../../helpers";
|
||||
import { cleanUpHandleId, getHandleId, updateUiOption } from "../../helpers";
|
||||
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
|
||||
import { ANY_OF_FLAG } from "../../constants";
|
||||
import { findCustomFieldId } from "../../registry";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const AnyOfField = (props: FieldProps) => {
|
||||
const { registry, schema } = props;
|
||||
@@ -21,6 +23,8 @@ export const AnyOfField = (props: FieldProps) => {
|
||||
field_id,
|
||||
} = useAnyOfField(props);
|
||||
|
||||
const isInputBroken = useNodeStore((state) => state.isInputBroken);
|
||||
|
||||
const parentCustomFieldId = findCustomFieldId(schema);
|
||||
if (parentCustomFieldId) {
|
||||
return null;
|
||||
@@ -43,6 +47,7 @@ export const AnyOfField = (props: FieldProps) => {
|
||||
});
|
||||
|
||||
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
|
||||
const optionCustomFieldId = optionSchema
|
||||
@@ -78,7 +83,11 @@ export const AnyOfField = (props: FieldProps) => {
|
||||
registry={registry}
|
||||
placeholder={props.placeholder}
|
||||
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}
|
||||
label=""
|
||||
hideLabel={true}
|
||||
@@ -93,7 +102,7 @@ export const AnyOfField = (props: FieldProps) => {
|
||||
selector={selector}
|
||||
uiSchema={updatedUiSchema}
|
||||
/>
|
||||
{!isHandleConnected && optionsSchemaField}
|
||||
{!isHandleConnected && !isAnyOfInputBroken && optionsSchemaField}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Text } from "@/components/atoms/Text/Text";
|
||||
import { isOptionalType } from "../../../utils/schema-utils";
|
||||
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
|
||||
interface customFieldProps extends FieldProps {
|
||||
selector: JSX.Element;
|
||||
@@ -51,6 +52,13 @@ export const AnyOfFieldTitle = (props: customFieldProps) => {
|
||||
shouldShowTypeSelector(schema) && !isArrayItem && !isHandleConnected;
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<TitleFieldTemplate
|
||||
@@ -62,8 +70,16 @@ export const AnyOfFieldTitle = (props: customFieldProps) => {
|
||||
uiSchema={uiSchema}
|
||||
/>
|
||||
{shoudlShowType && (
|
||||
<Text variant="small" className={cn("text-zinc-700", colorClass)}>
|
||||
{isOptional ? `(${displayType})` : "(any)"}
|
||||
<Text
|
||||
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>
|
||||
)}
|
||||
{shouldShowSelector && selector}
|
||||
|
||||
@@ -9,8 +9,9 @@ import { Text } from "@/components/atoms/Text/Text";
|
||||
import { getTypeDisplayInfo } from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
|
||||
import { isAnyOfSchema } from "../../utils/schema-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 { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||
|
||||
export default function TitleField(props: TitleFieldProps) {
|
||||
const { id, title, required, schema, registry, uiSchema } = props;
|
||||
@@ -26,6 +27,11 @@ export default function TitleField(props: TitleFieldProps) {
|
||||
const smallText = isArrayItemFlag || additional;
|
||||
|
||||
const showHandle = uiOptions.showHandles ?? showHandles;
|
||||
|
||||
const isInputBroken = useNodeStore((state) =>
|
||||
state.isInputBroken(nodeId, cleanUpHandleId(uiOptions.handleId)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{showHandle !== false && (
|
||||
@@ -34,7 +40,11 @@ export default function TitleField(props: TitleFieldProps) {
|
||||
<Text
|
||||
variant={isArrayItemFlag ? "small" : "body"}
|
||||
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}
|
||||
</Text>
|
||||
@@ -44,7 +54,7 @@ export default function TitleField(props: TitleFieldProps) {
|
||||
{!isAnyOf && (
|
||||
<Text
|
||||
variant="small"
|
||||
className={cn("ml-2", colorClass)}
|
||||
className={cn("ml-2", isInputBroken && "line-through", colorClass)}
|
||||
id={description_id}
|
||||
>
|
||||
({displayType})
|
||||
|
||||
@@ -30,6 +30,8 @@ export function updateUiOption<T extends Record<string, any>>(
|
||||
}
|
||||
|
||||
export const cleanUpHandleId = (handleId: string) => {
|
||||
if (!handleId) return "";
|
||||
|
||||
let newHandleId = handleId;
|
||||
if (handleId.includes(ANY_OF_FLAG)) {
|
||||
newHandleId = newHandleId.replace(ANY_OF_FLAG, "");
|
||||
|
||||
@@ -233,13 +233,14 @@ export default function useAgentGraph(
|
||||
title: `${block.name} ${node.id}`,
|
||||
inputSchema: block.inputSchema,
|
||||
outputSchema: block.outputSchema,
|
||||
isOutputStatic: block.staticOutput,
|
||||
hardcodedValues: node.input_default,
|
||||
uiType: block.uiType,
|
||||
metadata: metadata,
|
||||
connections: graph.links
|
||||
.filter((l) => [l.source_id, l.sink_id].includes(node.id))
|
||||
.map((link) => ({
|
||||
edge_id: formatEdgeID(link),
|
||||
id: formatEdgeID(link),
|
||||
source: link.source_id,
|
||||
sourceHandle: link.source_name,
|
||||
target: link.sink_id,
|
||||
|
||||
@@ -245,8 +245,8 @@ export type BlockIONullSubSchema = BlockIOSubSchemaMeta & {
|
||||
// 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.
|
||||
type BlockIOCombinedTypeSubSchema = BlockIOSubSchemaMeta & {
|
||||
type: never;
|
||||
const: never;
|
||||
type?: never;
|
||||
const?: never;
|
||||
} & (
|
||||
| {
|
||||
allOf: [BlockIOSimpleTypeSubSchema];
|
||||
@@ -368,8 +368,8 @@ export type GraphMeta = {
|
||||
recommended_schedule_cron: string | null;
|
||||
forked_from_id?: GraphID | null;
|
||||
forked_from_version?: number | null;
|
||||
input_schema: GraphIOSchema;
|
||||
output_schema: GraphIOSchema;
|
||||
input_schema: GraphInputSchema;
|
||||
output_schema: GraphOutputSchema;
|
||||
credentials_input_schema: CredentialsInputSchema;
|
||||
} & (
|
||||
| {
|
||||
@@ -385,19 +385,51 @@ export type GraphMeta = {
|
||||
export type GraphID = Brand<string, "GraphID">;
|
||||
|
||||
/* Derived from backend/data/graph.py:Graph._generate_schema() */
|
||||
export type GraphIOSchema = {
|
||||
export type GraphInputSchema = {
|
||||
type: "object";
|
||||
properties: Record<string, GraphIOSubSchema>;
|
||||
required: (keyof BlockIORootSchema["properties"])[];
|
||||
properties: Record<string, GraphInputSubSchema>;
|
||||
required: (keyof GraphInputSchema["properties"])[];
|
||||
};
|
||||
export type GraphIOSubSchema = Omit<
|
||||
BlockIOSubSchemaMeta,
|
||||
"placeholder" | "depends_on" | "hidden"
|
||||
> & {
|
||||
type: never; // bodge to avoid type checking hell; doesn't exist at runtime
|
||||
default?: string;
|
||||
export type GraphInputSubSchema = GraphOutputSubSchema &
|
||||
(
|
||||
| { type?: never; default: any | null } // AgentInputBlock (generic Any type)
|
||||
| { type: "string"; format: "short-text"; default: string | null } // AgentShortTextInputBlock
|
||||
| { type: "string"; format: "long-text"; default: string | null } // AgentLongTextInputBlock
|
||||
| { 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;
|
||||
metadata?: any;
|
||||
};
|
||||
|
||||
export type CredentialsInputSchema = {
|
||||
@@ -440,8 +472,8 @@ export type GraphUpdateable = Omit<
|
||||
is_active?: boolean;
|
||||
nodes: NodeCreatable[];
|
||||
links: LinkCreatable[];
|
||||
input_schema?: GraphIOSchema;
|
||||
output_schema?: GraphIOSchema;
|
||||
input_schema?: GraphInputSchema;
|
||||
output_schema?: GraphOutputSchema;
|
||||
};
|
||||
|
||||
export type GraphCreatable = _GraphCreatableInner & {
|
||||
@@ -497,8 +529,8 @@ export type LibraryAgent = {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions?: string | null;
|
||||
input_schema: GraphIOSchema;
|
||||
output_schema: GraphIOSchema;
|
||||
input_schema: GraphInputSchema;
|
||||
output_schema: GraphOutputSchema;
|
||||
credentials_input_schema: CredentialsInputSchema;
|
||||
new_output: boolean;
|
||||
can_access_graph: boolean;
|
||||
|
||||
@@ -6,7 +6,10 @@ import { NodeDimension } from "@/app/(platform)/build/components/legacy-builder/
|
||||
import {
|
||||
BlockIOObjectSubSchema,
|
||||
BlockIORootSchema,
|
||||
BlockIOSubSchema,
|
||||
Category,
|
||||
GraphInputSubSchema,
|
||||
GraphOutputSubSchema,
|
||||
} from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
@@ -76,8 +79,8 @@ export function getTypeBgColor(type: string | null): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function getTypeColor(type: string | null): string {
|
||||
if (type === null) return "#6b7280";
|
||||
export function getTypeColor(type: string | undefined): string {
|
||||
if (!type) return "#6b7280";
|
||||
return (
|
||||
{
|
||||
string: "#22c55e",
|
||||
@@ -88,11 +91,59 @@ export function getTypeColor(type: string | null): string {
|
||||
array: "#6366f1",
|
||||
null: "#6b7280",
|
||||
any: "#6b7280",
|
||||
"": "#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 {
|
||||
// Regular expression to identify places to split, considering acronyms
|
||||
const result = name
|
||||
|
||||
Reference in New Issue
Block a user